From 5e20bf43cbdbcce3b62496d1e2000705b828e0a4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 12:57:59 -0700 Subject: [PATCH 01/15] fix(chat): resolve a caller-supplied conversation id through its owner The v2 chat route used the caller-supplied conversationId verbatim, with no existence, owner, or workspace check, against a store keyed by bare text with no owner column. A caller who knew another user's conversation id reached that conversation. Ids now resolve through the same owner-scoped loader the web chat path uses, and anything unresolvable answers one uniform 404 before any lifecycle work runs. Omitting the id mints a server-issued conversation. The contract also accepted any 1-128 character string for a column typed uuid, so a malformed id raised a driver error and rendered 500 while an unknown but well-formed id rendered 404 - a shape oracle, and a 500 on ordinary input. The ownership predicate had no coverage anywhere: the route test mocked the module and the lifecycle test drove a chain mock that ignores its where clause, so deleting the owner condition left both suites green. It is now asserted by composition and by condition count, which is what catches a dropped condition. Also renames the reply's model identifier away from a term the project's own copy rules forbid on a user-facing surface. --- apps/sim/app/api/v2/chat/route.test.ts | 117 ++++++++++++++++-- apps/sim/app/api/v2/chat/route.ts | 34 ++++- apps/sim/lib/api/contracts/v2/chat.ts | 5 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 50 +++++++- .../src/commands/protocol/chat.test.ts | 2 +- 5 files changed, 190 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 5319c3e0913..76f5803ae4d 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -16,6 +16,7 @@ const { mockGenerateId, mockRequestExplicitStreamAbort, mockResolveBillingAttribution, + mockResolveOrCreateChat, mockRunHeadlessCopilotLifecycle, } = vi.hoisted(() => ({ MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, @@ -35,6 +36,7 @@ const { mockCheckPreAuthRate: vi.fn(), mockGenerateId: vi.fn(), mockResolveBillingAttribution: vi.fn(), + mockResolveOrCreateChat: vi.fn(), mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined), mockRunHeadlessCopilotLifecycle: vi.fn(), })) @@ -78,6 +80,10 @@ vi.mock('@/lib/copilot/chat/workspace-context', () => ({ generateWorkspaceContext: vi.fn().mockResolvedValue('workspace context'), })) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + resolveOrCreateChat: mockResolveOrCreateChat, +})) + vi.mock('@/lib/copilot/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]), })) @@ -111,6 +117,17 @@ const personalAuth = { keyType: 'personal', } +/** + * The route never echoes the caller's string back as the conversation id: it + * reports whatever the owner-scoped resolver returns. + */ +const SERVER_ISSUED_CHAT_ID = 'chat-server-1' +const OWNED_CONVERSATION_ID = '11111111-1111-4111-8111-111111111111' + +function chatRow(id: string) { + return { id, userId: 'user-1', workspaceId: 'workspace-1', workflowId: null, type: 'mothership' } +} + const successResult = { success: true, content: 'Hello there', @@ -144,6 +161,12 @@ describe('POST /api/v2/chat', () => { mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) mockRunHeadlessCopilotLifecycle.mockResolvedValue(successResult) + mockResolveOrCreateChat.mockResolvedValue({ + chatId: SERVER_ISSUED_CHAT_ID, + chat: chatRow(SERVER_ISSUED_CHAT_ID), + conversationHistory: [], + isNew: true, + }) }) it('rejects a missing or invalid API key', async () => { @@ -187,15 +210,15 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) - it('runs one turn and answers the reply with a generated conversation id', async () => { + it('runs one turn and answers the reply with a server-issued conversation id', async () => { const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) expect(response.status).toBe(200) const body = await response.json() expect(body.data).toEqual({ content: 'Hello there', - model: 'mothership', - conversationId: 'generated-1', + model: 'sim', + conversationId: SERVER_ISSUED_CHAT_ID, tokens: { prompt: 10, completion: 5, total: 15 }, cost: { total: 0.01 }, toolCalls: [{ name: 'run_workflow' }], @@ -206,7 +229,7 @@ describe('POST /api/v2/chat', () => { messages: [{ role: 'user', content: 'hi' }], userId: 'user-1', workspaceId: 'workspace-1', - chatId: 'generated-1', + chatId: SERVER_ISSUED_CHAT_ID, mode: 'agent', isHosted: true, workspaceContext: 'workspace context', @@ -216,7 +239,7 @@ describe('POST /api/v2/chat', () => { expect(options).toMatchObject({ userId: 'user-1', workspaceId: 'workspace-1', - chatId: 'generated-1', + chatId: SERVER_ISSUED_CHAT_ID, goRoute: '/api/mothership/execute', autoExecuteTools: true, interactive: false, @@ -230,17 +253,88 @@ describe('POST /api/v2/chat', () => { }) }) - it('continues the conversation the caller names', async () => { + it('mints a server-issued conversation when the caller names none', async () => { + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data.conversationId).toBe(SERVER_ISSUED_CHAT_ID) + const resolverInput = mockResolveOrCreateChat.mock.calls[0][0] as Record + expect(Object.hasOwn(resolverInput, 'chatId')).toBe(false) + expect(resolverInput).toMatchObject({ + userId: 'user-1', + workspaceId: 'workspace-1', + type: 'mothership', + }) + }) + + it('resolves a named conversation against the calling user and workspace before continuing it', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: chatRow(OWNED_CONVERSATION_ID), + conversationHistory: [], + isNew: false, + }) + const response = await callChat({ workspaceId: 'workspace-1', message: 'and then?', - conversationId: 'conv-9', + conversationId: OWNED_CONVERSATION_ID, }) expect(response.status).toBe(200) + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: OWNED_CONVERSATION_ID, + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ) + const body = await response.json() + expect(body.data.conversationId).toBe(OWNED_CONVERSATION_ID) + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ + chatId: OWNED_CONVERSATION_ID, + }) + }) + + it('answers 404 and runs nothing when the resolver refuses the named conversation', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: null, + conversationHistory: [], + isNew: false, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(response.status).toBe(404) const body = await response.json() - expect(body.data.conversationId).toBe('conv-9') - expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ chatId: 'conv-9' }) + expect(body.error.code).toBe('NOT_FOUND') + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: OWNED_CONVERSATION_ID, + userId: 'user-1', + workspaceId: 'workspace-1', + }) + ) + // No tokens may be billed against an id the caller could not be given. + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() + }) + + it('rejects a malformed conversation id before resolving anything', async () => { + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: 'not-a-conversation-id', + }) + + expect(response.status).toBe(400) + expect(mockResolveOrCreateChat).not.toHaveBeenCalled() + expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) it('answers a failed run as a 500 with the run error', async () => { @@ -282,7 +376,10 @@ describe('POST /api/v2/chat', () => { expect(chunks.map((chunk) => chunk.content)).toEqual(['Hello', ' there']) const final = events.at(-1) as { type: string; data: Record } expect(final.type).toBe('final') - expect(final.data).toMatchObject({ content: 'Hello there', conversationId: 'generated-1' }) + expect(final.data).toMatchObject({ + content: 'Hello there', + conversationId: SERVER_ISSUED_CHAT_ID, + }) }) it('ends the NDJSON stream with an error event when the run fails', async () => { diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index a7df7680448..46095172b14 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -13,6 +13,7 @@ import { } from '@/lib/api/server/routes' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { chatOperations } from '@/lib/copilot/application/operations' +import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' @@ -48,6 +49,12 @@ const CHAT_STREAM_VALUE = 'ndjson' const CHAT_HEARTBEAT_INTERVAL_MS = 15_000 const ndjsonEncoder = new TextEncoder() +/** + * Model recorded on conversations this route creates, matching the model the + * web Chat surface stamps on a new mothership conversation. + */ +const V2_CHAT_MODEL = 'claude-opus-4-8' + function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' } @@ -80,7 +87,7 @@ function buildChatResultPayload( return { content: result.content ?? '', - model: 'mothership', + model: 'sim', conversationId, tokens: result.usage ? { @@ -130,14 +137,35 @@ export const POST = withRouteHandler( if (!parsed.success) return parsed.response const { workspaceId, message, conversationId } = parsed.data.body - const chatId = conversationId || generateId() const messageId = generateId() const requestId = generateId() - const reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + let reqLogger = logger.withMetadata({ messageId, requestId }) try { const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + + // A caller-supplied conversation id is a claim, not an identity: resolve + // it through the same owner- and workspace-scoped loader the web Chat + // surface uses, and refuse every id that does not resolve with the same + // response so the refusal carries no information about the id. Omitting + // the id mints a server-issued conversation instead of trusting one. + const resolvedChat = await resolveOrCreateChat({ + ...(conversationId ? { chatId: conversationId } : {}), + userId, + workspaceId, + model: V2_CHAT_MODEL, + type: 'mothership', + }) + if (conversationId && !resolvedChat.chat) { + return v2Error('NOT_FOUND', 'Conversation not found') + } + if (!resolvedChat.chat || !resolvedChat.chatId) { + reqLogger.error('Failed to start a chat conversation', { userId, workspaceId }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } + const chatId = resolvedChat.chatId + reqLogger = logger.withMetadata({ chatId, messageId, requestId }) const secretMountPolicy = normalizeSecretMountPolicy(undefined) let environmentContext: CopilotEnvironmentContext | undefined diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts index 1f8170befbe..45cf303963e 100644 --- a/apps/sim/lib/api/contracts/v2/chat.ts +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -24,8 +24,7 @@ export const v2ChatBodySchema = z.object({ .describe('The message to send to Sim.'), conversationId: z .string() - .min(1, 'conversationId cannot be empty') - .max(128, 'conversationId cannot exceed 128 characters') + .uuid('conversationId must be a valid conversation id') .optional() .describe('Conversation to continue; a new one starts when omitted.'), }) @@ -39,7 +38,7 @@ const v2ChatTokensSchema = z.object({ export const v2ChatResultSchema = z.object({ content: z.string(), conversationId: z.string(), - model: z.string(), + model: z.string().describe('Identifier of the agent that produced the reply.'), tokens: v2ChatTokensSchema.optional(), // untyped-response: cost is a billing passthrough whose shape is owned by the copilot backend, not this contract cost: z.unknown().optional(), diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/copilot/chat/lifecycle.test.ts index 46e5c63dc31..2c6e1baf8df 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock, workflowAuthzMockFns } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -135,6 +135,54 @@ describe('lifecycle copilot chat reads (cutover to copilot_messages)', () => { expect(result?.messages).toEqual([userMsg]) }) + it('scopes the chat lookup to the requesting user, not the chat id alone', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await getAccessibleCopilotChatWithMessages(CHAT_ID, USER_ID) + + const predicate = dbChainMockFns.where.mock.calls[0]?.[0] as { + type: string + conditions: unknown[] + } + expect(predicate.type).toBe('and') + // Three conditions exactly: dropping one silently widens the lookup, so the + // count is asserted alongside the membership checks. + expect(predicate.conditions).toHaveLength(3) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: USER_ID, + }) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.id, + right: CHAT_ID, + }) + expect(predicate.conditions).toContainEqual({ + type: 'isNull', + column: schemaMock.copilotChats.deletedAt, + }) + }) + + it('resolveOrCreateChat scopes its existing-chat lookup to the requesting user', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + await resolveOrCreateChat({ chatId: CHAT_ID, userId: USER_ID, model: 'm' }) + + const predicate = dbChainMockFns.where.mock.calls[0]?.[0] as { + type: string + conditions: unknown[] + } + expect(predicate.conditions).toHaveLength(3) + expect(predicate.conditions).toContainEqual({ + type: 'eq', + left: schemaMock.copilotChats.userId, + right: USER_ID, + }) + }) + it('resolveOrCreateChat returns conversationHistory from the table for an existing chat', async () => { dbChainMockFns.limit.mockResolvedValueOnce([chatRow]) dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }, { content: asstMsg }]) diff --git a/packages/sim-cli/src/commands/protocol/chat.test.ts b/packages/sim-cli/src/commands/protocol/chat.test.ts index 2645a960265..0cba17c8c09 100644 --- a/packages/sim-cli/src/commands/protocol/chat.test.ts +++ b/packages/sim-cli/src/commands/protocol/chat.test.ts @@ -102,7 +102,7 @@ function written(spy: WriteSpy): string { const FINAL = { type: 'final', - data: { content: 'Hello there', conversationId: 'conv-1', model: 'mothership' }, + data: { content: 'Hello there', conversationId: 'conv-1', model: 'sim' }, } describe('sim chat', () => { From ebccc99aa6f1a9eecb3cddd10bff8396bcd50129 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 12:58:26 -0700 Subject: [PATCH 02/15] fix(v2): conceal workspace absence, and stop archived tables faulting their page Two reads answered a caller more than they were entitled to know. A workspace a caller cannot reach at all returned FORBIDDEN while one that does not exist returned NOT_FOUND, so a workspace-key holder could enumerate which workspace ids exist by diffing the two. Both now answer the same absence, using the concealment policy the billing routes already use. A refusal from inside the workspace - a member whose role is too low - still answers FORBIDDEN, because that caller already knows the workspace exists. Separately, archiving a folder cascades onto its tables but leaves each table pointing at the archived folder row. The archived listing resolved those paths strictly, so one such row faulted the whole page and no cursor could step past it - which also made the ids undiscoverable and left restore unreachable for exactly the tables that need it. The archived scope now resolves leniently to the root, where a restore would place them, matching the shipped workflows behavior. Active listings still fault loudly on a dangling folder. --- .../workspaces/[workspaceId]/members/route.ts | 10 +- .../v2/workspaces/[workspaceId]/route.test.ts | 126 ++++++++++++++++++ .../api/v2/workspaces/[workspaceId]/route.ts | 10 +- .../tools/server/table/user-table.test.ts | 1 + .../table/application/folder-paths.test.ts | 58 ++++++++ .../sim/lib/table/application/folder-paths.ts | 22 +++ apps/sim/lib/table/application/tables.test.ts | 65 ++++++++- apps/sim/lib/table/application/tables.ts | 11 +- apps/sim/lib/workspaces/api/route-policies.ts | 14 ++ 9 files changed, 300 insertions(+), 17 deletions(-) create mode 100644 apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts create mode 100644 apps/sim/lib/table/application/folder-paths.test.ts create mode 100644 apps/sim/lib/workspaces/api/route-policies.ts diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index 190d930ca5f..8df0265bdc6 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -3,13 +3,9 @@ import { v2WorkspaceMemberCursorSchema, } from '@/lib/api/contracts/v2/workspaces' import { cursorRoute, cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' import { workspaceOperations } from '@/lib/workspaces/application/operations' import { @@ -39,7 +35,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workspaceOperations.listPublicMembers, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ params, query }) => { const inner = readScopedCursor(query.cursor, memberCursorScope(params.workspaceId)) const decoded = inner ? v2WorkspaceMemberCursorSchema.safeParse(decodeCursor(inner)) : undefined diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts new file mode 100644 index 00000000000..2257e097400 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getWorkspace: vi.fn(), + listMembers: vi.fn(), +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) + +vi.mock('@/lib/workspaces/application/get-public-workspace', () => ({ + getPublicWorkspace: { + operation: { id: 'workspaces.read_public_detail' }, + execute: mocks.getWorkspace, + }, +})) + +vi.mock('@/lib/workspaces/application/list-public-workspace-members', () => ({ + listPublicWorkspaceMembers: { + operation: { id: 'workspaces.members.list_public' }, + execute: mocks.listMembers, + }, +})) + +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, + WorkspaceApiKeyScopeAuthorizationError, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as listMembers } from '@/app/api/v2/workspaces/[workspaceId]/members/route' +import { GET as getWorkspace } from '@/app/api/v2/workspaces/[workspaceId]/route' + +const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +/** + * The two reads a workspace id is addressable through. Both must conceal the + * same way, or the pair that still answers `403` is the oracle. + */ +const routes = [ + { + name: 'workspace detail', + spy: mocks.getWorkspace, + call: () => + getWorkspace(new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}`), { + params: Promise.resolve({ workspaceId: WORKSPACE_ID }), + }), + }, + { + name: 'member roster', + spy: mocks.listMembers, + call: () => + listMembers( + new NextRequest(`http://localhost:3000/api/v2/workspaces/${WORKSPACE_ID}/members`), + { params: Promise.resolve({ workspaceId: WORKSPACE_ID }) } + ), + }, +] as const + +describe.each(routes)('v2 $name workspace concealment', ({ spy, call }) => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + }) + + /** + * Asserted as equality between the two responses rather than against a + * literal: the leak is the DIFFERENCE, so a future rewording of either leg + * must not be able to reintroduce it while the test still passes. + */ + it.each([ + ['a workspace key scoped elsewhere', () => new WorkspaceApiKeyScopeAuthorizationError()], + ['a non-member personal key', () => new NoWorkspaceAccessError()], + ])('answers an unreachable workspace exactly as an absent one for %s', async (_label, raise) => { + spy.mockRejectedValueOnce(new OrchestrationError('not_found', 'Workspace not found')) + const absent = await call() + const absentBody = await absent.json() + + spy.mockRejectedValueOnce(raise()) + const unreachable = await call() + + expect(unreachable.status).toBe(absent.status) + expect(await unreachable.json()).toEqual(absentBody) + expect(absent.status).toBe(404) + expect(absentBody).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workspace not found' }, + }) + }) + + /** + * The negative leg. A caller already inside the workspace knows it exists, so + * a role refusal stays an actionable `403` — concealing it too would widen the + * policy past what it is for. + */ + it('still refuses an in-workspace role denial with 403', async () => { + spy.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await call() + + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) + }) +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index ecfdd075bde..bdcd9092d8b 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,10 +1,6 @@ import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2OrchestrationErrorPolicy, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' import { workspaceOperations } from '@/lib/workspaces/application/operations' @@ -14,7 +10,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: workspaceOperations.readPublicDetail, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ params }) => ({ workspaceId: params.workspaceId }), useCase: getPublicWorkspace, present: ({ workspace }) => ({ diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index 15fa0961a17..85d39c44e4d 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -169,6 +169,7 @@ vi.mock('@/lib/table/application/folder-paths', () => ({ index: { idByPath: new Map(), pathById: new Map() }, }), tableFolderPathForId: () => '/', + archivableTableFolderPath: () => '/', })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ diff --git a/apps/sim/lib/table/application/folder-paths.test.ts b/apps/sim/lib/table/application/folder-paths.test.ts new file mode 100644 index 00000000000..bc737b2f62b --- /dev/null +++ b/apps/sim/lib/table/application/folder-paths.test.ts @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import type { folder as folderTable } from '@sim/db/schema' +import { describe, expect, it } from 'vitest' +import { buildFolderPathIndex } from '@/lib/folders/paths' +import { + archivableTableFolderPath, + tableFolderPathForId, +} from '@/lib/table/application/folder-paths' + +const activeFolder = { + id: 'folder-active', + resourceType: 'table' as const, + name: 'Reports', + userId: 'owner-1', + workspaceId: 'workspace-1', + parentId: null, + sortOrder: 0, + locked: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, +} as typeof folderTable.$inferSelect + +/** + * The index the listing actually projects against: `loadActiveFolderPathIndex` + * filters on `isNull(deletedAt)`, so an archived folder is absent from it by + * construction and its id dangles. + */ +const index = buildFolderPathIndex([activeFolder]) +const ARCHIVED_FOLDER_ID = 'folder-archived' + +describe('table folder path projection', () => { + /** + * The scoping proof. A live table pointing at an unresolvable folder is a + * genuine inconsistency, so the strict projector every active-only call site + * uses must keep throwing on the very input the lenient one tolerates. + */ + it('throws on a dangling folder when the table is expected to be active', () => { + expect(() => tableFolderPathForId(index, ARCHIVED_FOLDER_ID)).toThrow( + 'Table references an inactive or missing folder' + ) + }) + + it('answers the root path instead, which is where restore would place it', () => { + expect(archivableTableFolderPath(index, ARCHIVED_FOLDER_ID)).toBe('/') + }) + + it('still resolves a folder that is active', () => { + expect(archivableTableFolderPath(index, activeFolder.id)).toBe('/Reports') + }) + + it('treats no folder as the root', () => { + expect(archivableTableFolderPath(index, null)).toBe('/') + expect(archivableTableFolderPath(index, undefined)).toBe('/') + }) +}) diff --git a/apps/sim/lib/table/application/folder-paths.ts b/apps/sim/lib/table/application/folder-paths.ts index e6864954eb1..0b45216362e 100644 --- a/apps/sim/lib/table/application/folder-paths.ts +++ b/apps/sim/lib/table/application/folder-paths.ts @@ -34,3 +34,25 @@ export function tableFolderPathForId( if (!path) throw new Error('Table references an inactive or missing folder') return path } + +/** + * The same projection for a table that may itself be archived. + * + * Archiving a folder cascades onto the tables inside it but leaves their + * `folderId` pointing at the now-inactive row — which is exactly why restore has + * to re-root a dangling `folderId`. So on any read that can surface an archived + * table, an unresolvable folder is the expected state rather than the + * inconsistency {@link tableFolderPathForId} treats it as, and one such row + * would otherwise throw a bare `Error` and 500 the whole page with no cursor + * position able to skip past it. + * + * The root is the honest answer: it is where restore would put the table if the + * caller restored it now. + */ +export function archivableTableFolderPath( + index: FolderPathIndex, + folderId: string | null | undefined +): string { + if (!folderId) return ROOT_FOLDER_PATH + return index.pathById.get(folderId) ?? ROOT_FOLDER_PATH +} diff --git a/apps/sim/lib/table/application/tables.test.ts b/apps/sim/lib/table/application/tables.test.ts index 16e58fe2791..ca3067a2bc2 100644 --- a/apps/sim/lib/table/application/tables.test.ts +++ b/apps/sim/lib/table/application/tables.test.ts @@ -59,9 +59,19 @@ vi.mock('@/lib/table/application/context', () => ({ resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) +/** + * The two projectors are deliberately distinguishable here: the strict one + * reproduces the bare `Error` a dangling `folderId` raises in production, so a + * listing that reaches for the wrong one fails the test the same way it 500s + * the page. + */ vi.mock('@/lib/table/application/folder-paths', () => ({ resolveTableFolderPath: vi.fn(), - tableFolderPathForId: () => '/', + tableFolderPathForId: (_index: unknown, folderId: string | null | undefined) => { + if (folderId) throw new Error('Table references an inactive or missing folder') + return '/' + }, + archivableTableFolderPath: () => '/', })) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) @@ -102,6 +112,59 @@ describe('table list scope', () => { mocks.queryTables.mockResolvedValue({ tables: [], nextKeys: null }) }) + /** + * Archiving a folder cascades onto its tables but leaves each `folderId` + * pointing at the soft-deleted row, so the archived scope is exactly the + * population whose folder cannot resolve. Projected strictly, one such row + * threw and 500'd the whole page — and no cursor position could step past it, + * which made every archived table id undiscoverable and `restore` unreachable. + */ + it('renders an archived table whose folder was archived too at the root', async () => { + mocks.queryTables.mockResolvedValue({ + tables: [{ ...ARCHIVED, folderId: 'folder-archived' }], + nextKeys: null, + }) + + const result = await listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + scope: 'archived', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + + expect(result.tables).toEqual([ + { table: { ...ARCHIVED, folderId: 'folder-archived' }, folderPath: '/' }, + ]) + }) + + /** + * The negative leg. A LIVE table pointing at a folder that does not resolve is + * a genuine inconsistency, so the active listing must stay loud rather than + * quietly re-rooting it. + */ + it('still fails loudly on a dangling folder in the active listing', async () => { + mocks.queryTables.mockResolvedValue({ + tables: [{ ...ARCHIVED, archivedAt: null, folderId: 'folder-archived' }], + nextKeys: null, + }) + + await expect( + listTablesUseCase.execute({ + principal: PRINCIPAL, + input: { + workspaceId: 'workspace-1', + sortBy: 'createdAt', + sortOrder: 'asc', + limit: 10, + }, + }) + ).rejects.toThrow('Table references an inactive or missing folder') + }) + it('lets the caller scope the listing without changing the default', async () => { await listTablesUseCase.execute({ principal: PRINCIPAL, diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index 8f4ca340b3c..d304efbe378 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -26,7 +26,11 @@ import { resolveArchivedTableContext, resolveTableWorkspaceContext, } from '@/lib/table/application/context' -import { resolveTableFolderPath, tableFolderPathForId } from '@/lib/table/application/folder-paths' +import { + archivableTableFolderPath, + resolveTableFolderPath, + tableFolderPathForId, +} from '@/lib/table/application/folder-paths' import { tableOperations } from '@/lib/table/application/operations' import { signalTableSchemaChanged } from '@/lib/table/events' @@ -68,7 +72,10 @@ export const listTablesUseCase = defineAuthorizedTableUseCase({ return { tables: tables.map((table) => ({ table, - folderPath: tableFolderPathForId(folderIndex, table.folderId), + folderPath: + input.scope === 'archived' + ? archivableTableFolderPath(folderIndex, table.folderId) + : tableFolderPathForId(folderIndex, table.folderId), })), nextKeys, sortBy: input.sortBy, diff --git a/apps/sim/lib/workspaces/api/route-policies.ts b/apps/sim/lib/workspaces/api/route-policies.ts new file mode 100644 index 00000000000..b765ace5b72 --- /dev/null +++ b/apps/sim/lib/workspaces/api/route-policies.ts @@ -0,0 +1,14 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * A caller naming a workspace it cannot reach must not be able to tell that + * refusal apart from a workspace that does not exist. Both answer + * `404 "Workspace not found"` — the message the unknown-workspace path in + * `get-public-workspace` and `list-public-workspace-members` already uses, so + * the two responses are byte-identical. + */ +export const v2WorkspaceErrorPolicies = { + concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + }), +} as const From fe1db96d9b004b80b29572aee9c3c65d716701f1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:00:21 -0700 Subject: [PATCH 03/15] fix(knowledge): validate upload processing options without stranding live sessions recipe and lang were accepted as free strings up to their length caps, silently discarded, and echoed back nowhere, so a typo was unobservable: uploading with a misspelled recipe returned 200 and quietly used the default. Both are now validated at the boundary and a bad value answers 400 naming what is accepted. The accepted recipe set deliberately includes the sentinel every first-party caller sends today alongside the three real chunker recipes, and the three are derived from the chunker's own union so removing one there is a compile error here rather than a silent 400 in production. The same schema also parses metadata read back off a persisted upload session, so tightening it would have thrown out of resume and complete for any session created before this - a 500 on work that could then never finish. The read-back path now drops a value it no longer recognises instead of rejecting it; the request boundary stays strict. Neither field reaches chunking, so nothing here moves chunk boundaries, embeddings, or search results. --- .../knowledge/hooks/use-knowledge-upload.ts | 3 +- apps/sim/lib/api/contracts/v2/knowledge.ts | 13 ++- .../application/upload-sessions.test.ts | 40 ++++++++ .../knowledge/application/upload-sessions.ts | 8 +- .../sim/lib/knowledge/upload-metadata.test.ts | 92 +++++++++++++++++++ apps/sim/lib/knowledge/upload-metadata.ts | 80 ++++++++++++++-- 6 files changed, 221 insertions(+), 15 deletions(-) create mode 100644 apps/sim/lib/knowledge/upload-metadata.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts index 90ae6736e20..1113e13cbc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' import type { V2KnowledgeDocumentSummary } from '@/lib/api/contracts/v2/knowledge' +import type { KnowledgeDocumentUploadRecipe } from '@/lib/knowledge/upload-metadata' import { assertMultiFileUploadAdmission, MultiFileUploadAdmissionError, @@ -49,7 +50,7 @@ export interface UploadError { } export interface ProcessingOptions { - recipe?: string + recipe?: KnowledgeDocumentUploadRecipe } export interface UseKnowledgeUploadOptions { diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 90ae85e4f81..332d7fc64f7 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -59,7 +59,10 @@ import { rerankerModelSchema, rerankerStatusSchema, } from '@/lib/knowledge/reranker-models' -import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' +import { + KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, + knowledgeDocumentUploadMetadataSchema, +} from '@/lib/knowledge/upload-metadata' import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types' /** @@ -481,10 +484,14 @@ const v2KnowledgeDocumentProcessingOptionsSchema = .extend({ recipe: knowledgeDocumentUploadMetadataSchema.shape.processingOptions .unwrap() - .shape.recipe.describe('Optional document processing recipe.'), + .shape.recipe.describe( + `Optional document processing recipe. One of: ${KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES.join(', ')}.` + ), lang: knowledgeDocumentUploadMetadataSchema.shape.processingOptions .unwrap() - .shape.lang.describe('Optional document language code.'), + .shape.lang.describe( + 'Optional document language, as a BCP-47 tag such as `en` or `en-US`.' + ), }) .strict() diff --git a/apps/sim/lib/knowledge/application/upload-sessions.test.ts b/apps/sim/lib/knowledge/application/upload-sessions.test.ts index 796f0b3c820..57b109eb481 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.test.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.test.ts @@ -362,6 +362,46 @@ describe('knowledge-document upload application lifecycle', () => { ) }) + it('completes a session whose persisted recipe and lang predate their validation', async () => { + mocks.getUpload.mockResolvedValue({ + ...SESSION, + metadata: { + ...SESSION.metadata, + processingOptions: { recipe: 'super-chunker-9000', lang: 'en_US' }, + }, + }) + mocks.completeUpload.mockImplementation( + async (params: { + session: UploadSessionRecord + finalize: (session: UploadSessionRecord) => Promise<{ + value: { document: typeof DOCUMENT; created: boolean; knowledgeBaseName: string | null } + completedFileId?: string + }> + }) => { + const finalized = await params.finalize(params.session) + return { + session: { ...params.session, status: 'completed' as const }, + value: finalized.value, + alreadyCompleted: false, + } + } + ) + + const result = await completeKnowledgeDocumentUpload.execute({ + principal: PRINCIPAL, + input: { + knowledgeBaseId: 'knowledge-1', + assertedWorkspaceId: 'workspace-1', + uploadId: 'upload-1', + uploadToken: 'token', + source: 'api', + }, + request: REQUEST, + }) + + expect(result.value.created).toBe(true) + }) + it('returns an already-bound document without re-billing, re-registering, or auditing', async () => { mocks.findBound.mockResolvedValue({ status: 'bound', document: DOCUMENT }) mocks.completeUpload.mockImplementation( diff --git a/apps/sim/lib/knowledge/application/upload-sessions.ts b/apps/sim/lib/knowledge/application/upload-sessions.ts index 1d1ed33d2c1..7abafbfe954 100644 --- a/apps/sim/lib/knowledge/application/upload-sessions.ts +++ b/apps/sim/lib/knowledge/application/upload-sessions.ts @@ -23,7 +23,7 @@ import type { CreatedKnowledgeDocument } from '@/lib/knowledge/orchestration/doc import { findBoundKnowledgeDocument } from '@/lib/knowledge/orchestration/documents' import { type KnowledgeDocumentUploadMetadata, - knowledgeDocumentUploadMetadataSchema, + persistedKnowledgeDocumentUploadMetadataSchema, } from '@/lib/knowledge/upload-metadata' import { recordKnowledgeBaseFileOwnership } from '@/lib/uploads/server/metadata' import { requestOrigin } from '@/lib/uploads/upload-session/application' @@ -456,9 +456,13 @@ async function reauthorizeKnowledgeDocumentUpload( return context } +/** + * Reads metadata back off a persisted session, so it uses the lenient schema: + * a session created before `recipe`/`lang` were constrained must still resume. + */ function knowledgeDocumentMetadataFor(session: UploadSessionRecord) { const { authBinding: _authBinding, ...metadata } = session.metadata - return knowledgeDocumentUploadMetadataSchema.parse(metadata) + return persistedKnowledgeDocumentUploadMetadataSchema.parse(metadata) } function knowledgeDocumentInputFor(session: UploadSessionRecord) { diff --git a/apps/sim/lib/knowledge/upload-metadata.test.ts b/apps/sim/lib/knowledge/upload-metadata.test.ts new file mode 100644 index 00000000000..56e8475d938 --- /dev/null +++ b/apps/sim/lib/knowledge/upload-metadata.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, + knowledgeDocumentUploadMetadataSchema, + persistedKnowledgeDocumentUploadMetadataSchema, +} from '@/lib/knowledge/upload-metadata' + +describe('knowledgeDocumentUploadMetadataSchema', () => { + it('rejects a recipe outside the accepted set', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { recipe: 'totally-bogus-recipe' }, + }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'recipe']) + expect(result.error?.issues[0]?.message).toContain('recipe must be one of') + }) + + it('rejects a lang that is not a BCP-47 tag', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { lang: 'zzzz-nonsense!' }, + }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.message).toContain('BCP-47') + }) + + it('rejects the underscore locale form callers reach for', () => { + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'en_US' } }) + .success + ).toBe(false) + }) + + it('accepts what first-party callers actually send today', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + tag1: 'product', + processingOptions: { recipe: 'default', lang: 'en' }, + }) + expect(result.success).toBe(true) + expect(result.data?.processingOptions).toEqual({ recipe: 'default', lang: 'en' }) + }) + + it('accepts a multi-subtag BCP-47 tag', () => { + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'zh-Hant-TW' } }) + .success + ).toBe(true) + }) + + it('keeps the chunker recipes accepted alongside the default sentinel', () => { + expect(KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES).toContain('default') + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { recipe: 'markdown' } }) + .success + ).toBe(true) + }) +}) + +describe('persistedKnowledgeDocumentUploadMetadataSchema', () => { + it('drops a recipe persisted before the enum landed instead of throwing', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + tag1: 'product', + processingOptions: { recipe: 'super-chunker-9000', lang: 'en' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: undefined, lang: 'en' }) + expect(parsed.tag1).toBe('product') + }) + + it('drops a lang persisted before the BCP-47 shape landed instead of throwing', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 'default', lang: 'en_US' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: 'default', lang: undefined }) + }) + + it('does not throw on a session whose processing options are wholly unrecognized', () => { + expect(() => + persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 42, lang: false }, + }) + ).not.toThrow() + }) + + it('preserves recognized values', () => { + const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ + processingOptions: { recipe: 'code', lang: 'en-US' }, + }) + expect(parsed.processingOptions).toEqual({ recipe: 'code', lang: 'en-US' }) + }) +}) diff --git a/apps/sim/lib/knowledge/upload-metadata.ts b/apps/sim/lib/knowledge/upload-metadata.ts index ec5761b925d..ddb32ea21d7 100644 --- a/apps/sim/lib/knowledge/upload-metadata.ts +++ b/apps/sim/lib/knowledge/upload-metadata.ts @@ -1,24 +1,63 @@ import { z } from 'zod' +import type { RecursiveRecipe } from '@/lib/chunkers/types' + +/** + * Recipes the recursive chunker implements. Mirrors `RecursiveRecipe`; the + * `satisfies` keeps a rename or removal there a compile error here. + */ +const RECURSIVE_RECIPES = [ + 'plain', + 'markdown', + 'code', +] as const satisfies readonly RecursiveRecipe[] + +/** + * Recipes accepted on a document upload. `'default'` is not a chunker recipe — + * it is the long-standing sentinel every first-party caller sends to mean "use + * the knowledge base's configured strategy", so it must stay accepted. + */ +export const KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES = ['default', ...RECURSIVE_RECIPES] as const + +export type KnowledgeDocumentUploadRecipe = (typeof KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES)[number] + +/** + * BCP-47 language tag: a 2-8 letter primary subtag followed by any number of + * alphanumeric subtags, e.g. `en`, `en-US`, `zh-Hant-TW`. + */ +const BCP47_LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/ const knowledgeDocumentUploadTagSchema = z .string() .max(1000, 'Knowledge document tag values cannot exceed 1000 characters') .optional() +const knowledgeDocumentUploadTagShape = { + tag1: knowledgeDocumentUploadTagSchema, + tag2: knowledgeDocumentUploadTagSchema, + tag3: knowledgeDocumentUploadTagSchema, + tag4: knowledgeDocumentUploadTagSchema, + tag5: knowledgeDocumentUploadTagSchema, + tag6: knowledgeDocumentUploadTagSchema, + tag7: knowledgeDocumentUploadTagSchema, +} + +const recipeSchema = z.enum(KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, { + error: `recipe must be one of: ${KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES.join(', ')}`, +}) + +const langSchema = z + .string() + .max(35, 'lang cannot exceed 35 characters') + .regex(BCP47_LANGUAGE_TAG, 'lang must be a BCP-47 language tag, for example "en" or "en-US"') + /** Persisted metadata stored with a resumable Knowledge document upload session. */ export const knowledgeDocumentUploadMetadataSchema = z .object({ - tag1: knowledgeDocumentUploadTagSchema, - tag2: knowledgeDocumentUploadTagSchema, - tag3: knowledgeDocumentUploadTagSchema, - tag4: knowledgeDocumentUploadTagSchema, - tag5: knowledgeDocumentUploadTagSchema, - tag6: knowledgeDocumentUploadTagSchema, - tag7: knowledgeDocumentUploadTagSchema, + ...knowledgeDocumentUploadTagShape, processingOptions: z .object({ - recipe: z.string().max(255, 'recipe cannot exceed 255 characters').optional(), - lang: z.string().max(35, 'lang cannot exceed 35 characters').optional(), + recipe: recipeSchema.optional(), + lang: langSchema.optional(), }) .strict() .optional(), @@ -26,3 +65,26 @@ export const knowledgeDocumentUploadMetadataSchema = z .strict() export type KnowledgeDocumentUploadMetadata = z.output + +/** + * Read-back variant for metadata already persisted on an upload session. + * + * The strict schema above is a *request* boundary and rejects an unrecognized + * `recipe`/`lang`. Sessions created before those constraints existed can carry + * values that no longer parse, and rejecting them here would throw a raw + * `ZodError` out of resume/complete — a 500 on a session that could never be + * finished. Unrecognized values are dropped instead; neither field affects + * processing today, so dropping one changes nothing but the analytics property. + */ +export const persistedKnowledgeDocumentUploadMetadataSchema = z + .object({ + ...knowledgeDocumentUploadTagShape, + processingOptions: z + .object({ + recipe: recipeSchema.optional().catch(undefined), + lang: langSchema.optional().catch(undefined), + }) + .strict() + .optional(), + }) + .strict() From ca2d0bb430b41839aab7fc8a3b2ca5e87d4f29f6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:03:04 -0700 Subject: [PATCH 04/15] fix(v2): honour a requested stats window, and answer a claimed graph id with a conflict Log statistics accepted a start and an end, filtered the totals by them, and then built the series against wall-clock now. Bucket width was computed over a span the caller never asked for, and every bucket past the requested end was structurally empty - so a bounded historical query returned a wrong-width series with fabricated trailing buckets, under a window label that disagreed with the request. Each edge now honours the bound it was given and keeps its previous derivation when omitted, so an unbounded request is unchanged. Separately, block, edge and subflow ids are global primary keys while the delete that precedes a state replace is scoped to one workflow. An id owned by another workflow survived that delete, the insert violated the key, and because callers pass their own transaction the driver error escaped unclassified as a server fault. The write now refuses such an id up front with a conflict naming it, and re-classifies the same violation if one races past the check, since the lock covers only the workflow being written. The dry run checks the ids a commit would insert and reports the warnings a commit would report, which is what its own contract already promised. --- apps/sim/app/api/logs/stats/route.ts | 5 +- apps/sim/lib/api/contracts/v2/logs-stats.ts | 2 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 2 +- .../sim/lib/logs/application/get-log-stats.ts | 5 +- .../log-analytics-use-cases.test.ts | 30 ++++ apps/sim/lib/logs/stats.test.ts | 87 +++++++++- apps/sim/lib/logs/stats.ts | 54 ++++-- .../replace-workflow-state.test.ts | 85 +++++++++ .../application/replace-workflow-state.ts | 23 ++- .../replace-normalized-state.test.ts | 130 +++++++++++++- .../persistence/replace-normalized-state.ts | 164 +++++++++++++++++- 11 files changed, 562 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/api/logs/stats/route.ts b/apps/sim/app/api/logs/stats/route.ts index 35649fa28f0..7231af0bfd7 100644 --- a/apps/sim/app/api/logs/stats/route.ts +++ b/apps/sim/app/api/logs/stats/route.ts @@ -72,7 +72,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter const bounds = await readLogStatsBounds(whereCondition) - const window = resolveLogStatsWindow(bounds, params.segmentCount) + const window = resolveLogStatsWindow(bounds, params.segmentCount, { + requestedStart: params.startDate ? new Date(params.startDate) : undefined, + requestedEnd: params.endDate ? new Date(params.endDate) : undefined, + }) const rows = await readLogStatsSegments( whereCondition, window.startTime.toISOString(), diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index e201d5de7df..e48c5bca285 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -105,7 +105,7 @@ export const v2LogStatsSchema = z end: v2TimestampSchema.describe('ISO 8601 end of the window.'), }) .describe( - 'The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours.' + 'The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the window is 24 hours wide, measured back from that right edge — the trailing 24 hours when no `endDate` was supplied, and the 24 hours preceding `endDate` when one was supplied without a `startDate`.' ), segmentMs: z.number().describe('Width of one bucket in milliseconds.'), }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index f39c21cf054..c56545adfab 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -207,7 +207,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'getLogStats', summary: 'Get Log Statistics', - description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the window is 24 hours wide, measured back from that right edge — the trailing 24 hours when no \`endDate\` was supplied, and the 24 hours preceding \`endDate\` when one was supplied without a \`startDate\`. The window is divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Bucketed execution statistics for the workspace.' }, }), diff --git a/apps/sim/lib/logs/application/get-log-stats.ts b/apps/sim/lib/logs/application/get-log-stats.ts index 953f26e5b01..c037049451a 100644 --- a/apps/sim/lib/logs/application/get-log-stats.ts +++ b/apps/sim/lib/logs/application/get-log-stats.ts @@ -47,7 +47,10 @@ export const getLogStats = defineAuthorizedWorkspaceUseCase({ ) const bounds = await readLogStatsBounds(where) - const window = resolveLogStatsWindow(bounds, input.segmentCount) + const window = resolveLogStatsWindow(bounds, input.segmentCount, { + requestedStart: input.filters.startDate, + requestedEnd: input.filters.endDate, + }) const rows = await readLogStatsSegments(where, window.startTime.toISOString(), window.segmentMs) return buildDashboardStats(rows, window, input.segmentCount, { maxWorkflows: MAX_STATS_WORKFLOWS, diff --git a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts index d632f95666f..125ab8c6f67 100644 --- a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts +++ b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts @@ -129,6 +129,36 @@ describe('getLogStats', () => { ) }) + /** + * The wiring, not the arithmetic: `resolveLogStatsWindow` is exercised for + * real here, so a requested window that never reaches it shows up as both a + * wrong segment origin on the read and a wrong `timeBounds` on the response. + */ + it('spans the requested window rather than the rows that happen to exist', async () => { + const { stats } = await getLogStats.execute({ + principal: workspacePrincipal, + input: { + workspaceId: 'workspace-1', + filters: { + startDate: new Date('2026-08-01T00:00:00.000Z'), + endDate: new Date('2026-08-02T00:00:00.000Z'), + }, + segmentCount: 2, + }, + }) + + expect(mocks.readSegments).toHaveBeenCalledWith( + expect.anything(), + '2026-08-01T00:00:00.000Z', + 12 * 60 * 60 * 1000 + ) + expect(stats.timeBounds).toEqual({ + start: '2026-08-01T00:00:00.000Z', + end: '2026-08-02T00:00:00.000Z', + }) + expect(stats.segmentMs).toBe(12 * 60 * 60 * 1000) + }) + it('resolves the folder scope only after authorization, and only when asked', async () => { await getLogStats.execute({ principal: workspacePrincipal, diff --git a/apps/sim/lib/logs/stats.test.ts b/apps/sim/lib/logs/stats.test.ts index e645997fef8..087cf4717dc 100644 --- a/apps/sim/lib/logs/stats.test.ts +++ b/apps/sim/lib/logs/stats.test.ts @@ -29,7 +29,7 @@ describe('resolveLogStatsWindow', () => { const now = new Date('2026-01-15T12:00:00.000Z') it('falls back to the trailing 24 hours when nothing ran', () => { - const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, now) + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { now }) expect(resolved.endTime).toEqual(now) expect(resolved.startTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) @@ -39,7 +39,7 @@ describe('resolveLogStatsWindow', () => { const resolved = resolveLogStatsWindow( { minTime: '2026-01-15T00:00:00.000Z', maxTime: '2026-01-15T06:00:00.000Z' }, 12, - now + { now } ) expect(resolved.endTime).toEqual(now) @@ -50,17 +50,96 @@ describe('resolveLogStatsWindow', () => { const resolved = resolveLogStatsWindow( { minTime: '2026-01-15T12:00:00.000Z', maxTime: '2026-01-15T12:00:01.000Z' }, 500, - now + { now } ) expect(resolved.segmentMs).toBe(60_000) }) it('divides by segmentCount without producing a zero-width bucket', () => { - const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 1, now) + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 1, { now }) expect(resolved.segmentMs).toBe(24 * 60 * 60 * 1000) }) + + /** + * The `segmentMs` assertion is the load-bearing half: pinning `endTime` + * alone would still pass for a fix that relabelled `timeBounds` without + * re-deriving the bucket width the series is stamped from. + */ + it('ends the window at the requested end rather than at now', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-14T00:00:00.000Z', maxTime: '2026-01-14T06:00:00.000Z' }, + 12, + { requestedEnd: new Date('2026-01-14T12:00:00.000Z'), now } + ) + + expect(resolved.endTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) + expect(resolved.segmentMs).toBe(60 * 60 * 1000) + }) + + it('starts the window at the requested start rather than at the oldest run', () => { + const resolved = resolveLogStatsWindow( + { minTime: '2026-01-14T06:00:00.000Z', maxTime: '2026-01-14T09:00:00.000Z' }, + 12, + { + requestedStart: new Date('2026-01-14T00:00:00.000Z'), + requestedEnd: new Date('2026-01-14T12:00:00.000Z'), + now, + } + ) + + expect(resolved.startTime).toEqual(new Date('2026-01-14T00:00:00.000Z')) + expect(resolved.segmentMs).toBe(60 * 60 * 1000) + }) + + it('reports the requested window when nothing ran inside it', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 6, { + requestedStart: new Date('2026-01-01T00:00:00.000Z'), + requestedEnd: new Date('2026-01-07T00:00:00.000Z'), + now, + }) + + expect(resolved.startTime).toEqual(new Date('2026-01-01T00:00:00.000Z')) + expect(resolved.endTime).toEqual(new Date('2026-01-07T00:00:00.000Z')) + expect(resolved.segmentMs).toBe(24 * 60 * 60 * 1000) + }) + + /** + * The case neither fallback sentence covers on its own: with no rows and only + * a right edge, the 24-hour window is measured back from the requested end, + * not from the wall clock. + */ + it('measures the empty-result fallback back from a requested end', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { + requestedEnd: new Date('2026-01-10T00:00:00.000Z'), + now, + }) + + expect(resolved.endTime).toEqual(new Date('2026-01-10T00:00:00.000Z')) + expect(resolved.startTime).toEqual(new Date('2026-01-09T00:00:00.000Z')) + }) + + /** The dashboard schema has no `startDate <= endDate` refinement, so a crossed pair reaches here. */ + it('keeps a crossed requested pair from producing a non-positive bucket width', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 6, { + requestedStart: new Date('2026-01-07T00:00:00.000Z'), + requestedEnd: new Date('2026-01-01T00:00:00.000Z'), + now, + }) + + expect(resolved.segmentMs).toBe(60_000) + }) + + it('ignores an unparseable requested bound instead of stamping Invalid Date', () => { + const resolved = resolveLogStatsWindow({ minTime: null, maxTime: null }, 24, { + requestedEnd: new Date('not-a-date'), + now, + }) + + expect(resolved.endTime).toEqual(now) + expect(resolved.startTime).toEqual(new Date('2026-01-14T12:00:00.000Z')) + }) }) describe('buildDashboardStats', () => { diff --git a/apps/sim/lib/logs/stats.ts b/apps/sim/lib/logs/stats.ts index a7dd2ba6dd2..3b1a287b4b4 100644 --- a/apps/sim/lib/logs/stats.ts +++ b/apps/sim/lib/logs/stats.ts @@ -11,32 +11,64 @@ export interface LogStatsWindow { segmentMs: number } +/** Requested window and clock overrides for {@link resolveLogStatsWindow}. */ +export interface ResolveLogStatsWindowOptions { + /** Caller-supplied left edge, when the request named one. */ + requestedStart?: Date + /** Caller-supplied right edge, when the request named one. */ + requestedEnd?: Date + /** Wall clock, injectable so the fallbacks are deterministic under test. */ + now?: Date +} + +/** An unparseable bound is treated as absent rather than as `Invalid Date`. */ +function usableBound(bound: Date | undefined): Date | undefined { + return bound && Number.isFinite(bound.getTime()) ? bound : undefined +} + /** - * The window the segments span, derived from the rows that exist rather than - * from a caller-supplied range. + * The window the segments span. + * + * An edge the caller named wins outright: no run outside it can be counted, so + * deriving the span from anything else stamps trailing buckets the query has + * already excluded and computes `segmentMs` over a width nobody asked for. * - * A workspace with no runs still has to answer with a window, because + * An omitted edge still falls back to the rows that exist — the oldest matching + * run on the left, and on the right the later of the newest matching run and + * `now`, so a live dashboard's right edge is the present rather than the last + * thing that happened. + * + * A workspace with no matching runs still has to answer with a window, because * `segmentMs` and every segment timestamp are computed from one — hence the - * trailing-24-hour fallback. The end is pushed to `now` whenever the newest run - * is older than that, so a live dashboard's right edge is the present rather - * than the last thing that happened. + * 24-hour fallback. It is measured back from the right edge, so an empty result + * with no bounds reports the trailing 24 hours, and an empty result with only + * an `endDate` reports the 24 hours preceding that date. */ export function resolveLogStatsWindow( bounds: LogStatsBounds, segmentCount: number, - now: Date = new Date() + options: ResolveLogStatsWindowOptions = {} ): LogStatsWindow { + const requestedStart = usableBound(options.requestedStart) + const requestedEnd = usableBound(options.requestedEnd) + const now = options.now ?? new Date() + let startTime: Date let endTime: Date if (!bounds.minTime || !bounds.maxTime) { - endTime = now - startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = requestedEnd ?? now + startTime = requestedStart ?? new Date(endTime.getTime() - 24 * 60 * 60 * 1000) } else { - startTime = new Date(bounds.minTime) - endTime = new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) + startTime = requestedStart ?? new Date(bounds.minTime) + endTime = requestedEnd ?? new Date(Math.max(new Date(bounds.maxTime).getTime(), now.getTime())) } + /** + * A crossed pair reaches here from the first-party dashboard, whose query + * schema carries no `startDate <= endDate` refinement, so the floor is what + * keeps `segmentMs` positive instead of zero or negative. + */ const totalMs = Math.max(1, endTime.getTime() - startTime.getTime()) return { startTime, diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts index 60e5552712a..8e3354813f6 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -11,6 +11,9 @@ const mocks = vi.hoisted(() => ({ resolvePermission: vi.fn(), notify: vi.fn(), replace: vi.fn(), + prepare: vi.fn(), + collectGraphIds: vi.fn(), + assertIdsUnclaimed: vi.fn(), validate: vi.fn(), needsRedeployment: vi.fn(), })) @@ -35,8 +38,13 @@ vi.mock('@/lib/workflows/application/context', () => ({ resolveActiveWorkflowApplicationContext: mocks.resolveContext, })) vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/prepare-state', () => ({ + prepareWorkflowStateForPersistence: mocks.prepare, +})) vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ replaceWorkflowNormalizedState: mocks.replace, + collectWorkflowGraphIds: mocks.collectGraphIds, + assertWorkflowGraphIdsUnclaimed: mocks.assertIdsUnclaimed, })) vi.mock('@/lib/workflows/sanitization/validation', () => ({ validateWorkflowState: mocks.validate, @@ -88,6 +96,12 @@ describe('replaceWorkflowState', () => { state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, }) mocks.needsRedeployment.mockResolvedValue(true) + mocks.prepare.mockReturnValue({ + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + warnings: [], + }) + mocks.collectGraphIds.mockReturnValue({ blockIds: ['block-1'], edgeIds: [], subflowIds: [] }) + mocks.assertIdsUnclaimed.mockResolvedValue(undefined) }) /** @@ -333,6 +347,77 @@ describe('replaceWorkflowState', () => { expect(dry.edgesCount).toBe(committed.edgesCount) }) + /** + * The preview promised in {@link ReplaceWorkflowStateInput.dryRun} is + * byte-identical to the committed write of the same body, and preparation + * is where a dropped edge or a stripped inline secret is noted. Reporting + * only the validation half made the dry run quietly less informative than + * the write it previews. + */ + it('merges the preparation warnings a committed write would report', async () => { + mocks.validate.mockReturnValue({ + valid: true, + errors: [], + warnings: ['Dropped block "block-2"'], + }) + mocks.prepare.mockReturnValue({ + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + warnings: ['Dropped edge "edge-9": target block does not exist'], + }) + mocks.replace.mockResolvedValue({ + warnings: ['Dropped edge "edge-9": target block does not exist'], + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + }) + + const dry = await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + const committed = await replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + + expect(dry.warnings).toEqual([ + 'Dropped block "block-2"', + 'Dropped edge "edge-9": target block does not exist', + ]) + expect(dry.warnings).toEqual(committed.warnings) + }) + + /** + * A dry run that reports clean for a body that cannot commit is worse than + * the fault it hides. It checks the ids the write would actually insert — + * the prepared graph's, not the caller's body's. + */ + it('refuses a graph whose ids another workflow already owns', async () => { + mocks.assertIdsUnclaimed.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Block ids already used by another workflow: block-1') + ) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('checks the ids the prepared graph would insert, not the ids sent', async () => { + const prepared = { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} } + mocks.prepare.mockReturnValue({ state: prepared, warnings: [] }) + + await replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, dryRun: true }, + }) + + expect(mocks.collectGraphIds).toHaveBeenCalledWith(prepared) + expect(mocks.assertIdsUnclaimed).toHaveBeenCalledWith(expect.anything(), 'workflow-1', { + blockIds: ['block-1'], + edgeIds: [], + subflowIds: [], + }) + }) + /** A locked workflow refuses the preview too, or the preview would lie. */ it('refuses when the workflow cannot be mutated', async () => { workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValueOnce( diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts index 5dff1adfa36..627fce476a4 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -5,6 +5,7 @@ import { requirePrincipalSubjectUserId, resolvePrincipalAttribution, } from '@sim/auth/principal' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { principalAuditSource } from '@/lib/core/application' @@ -19,7 +20,12 @@ import { normalizeWorkflowVariables } from '@/lib/workflows/application/workflow import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' import type { WorkflowLintReport } from '@/lib/workflows/editing/lint' import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' -import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { + assertWorkflowGraphIdsUnclaimed, + collectWorkflowGraphIds, + replaceWorkflowNormalizedState, +} from '@/lib/workflows/persistence/replace-normalized-state' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' const logger = createLogger('ReplaceWorkflowState') @@ -136,6 +142,19 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ }) if (input.dryRun) { + /** + * The same preparation the committed write runs, so a dry run reports the + * notes that write would produce and checks the ids it would actually + * insert — the prepared graph, not the caller's body. Preparing here and + * again inside the write is the cost of the two paths never disagreeing. + */ + const prepared = prepareWorkflowStateForPersistence(graph) + await assertWorkflowGraphIdsUnclaimed( + db, + context.workflowId, + collectWorkflowGraphIds(prepared.state) + ) + logger.info('Validated workflow state without persisting', { workflowId: context.workflowId, workspaceId: context.workspaceId, @@ -147,7 +166,7 @@ export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, blocksCount: Object.keys(graph.blocks).length, edgesCount: graph.edges.length, - warnings: validation.warnings, + warnings: [...validation.warnings, ...prepared.warnings], needsRedeployment: await checkNeedsRedeployment(context.workflowId), lint, dryRun: true, diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 5acceca9fb5..80d1e9d7759 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -1,7 +1,8 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { and, inArray, ne } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -147,6 +148,133 @@ describe('replaceWorkflowNormalizedState', () => { expect(mocks.extractCustomTools).not.toHaveBeenCalled() }) + /** + * `workflow_blocks.id`, `workflow_edges.id`, and `workflow_subflows.id` are + * GLOBAL primary keys while the replace deletes only this workflow's rows, so + * an id owned elsewhere survives the delete and faults the insert. These pin + * the refusal, the message, and — because `dbChainMock` resolves rows without + * evaluating predicates — the predicate itself. + */ + describe('ids claimed by another workflow', () => { + const LOOP_BLOCK = { ...BLOCK, id: 'loop-1', type: 'loop', name: 'Loop' } + const EDGE = { id: 'edge-1', source: 'block-1', target: 'block-1' } + + it('refuses a block id another workflow owns instead of faulting the insert', async () => { + queueTableRows(schemaMock.workflowBlocks, [{ id: 'block-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Block ids already used by another workflow: block-1', + }) + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** + * The `ne(workflowId)` half is load-bearing: without it every ordinary + * round-trip — re-sending ids this workflow already holds — becomes a 409. + * Asserted on the composed condition, because the chain mock resolves rows + * regardless of what was passed to `where`. + */ + it('scopes the lookup to ids held by a DIFFERENT workflow', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowBlocks.id, ['block-1']) + expect(ne).toHaveBeenCalledWith(schemaMock.workflowBlocks.workflowId, 'workflow-1') + expect(and).toHaveBeenCalledWith( + { type: 'inArray', column: schemaMock.workflowBlocks.id, values: ['block-1'] }, + { type: 'ne', left: schemaMock.workflowBlocks.workflowId, right: 'workflow-1' } + ) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'inArray', column: schemaMock.workflowBlocks.id, values: ['block-1'] }, + { type: 'ne', left: schemaMock.workflowBlocks.workflowId, right: 'workflow-1' }, + ], + }) + expect(mocks.save).toHaveBeenCalled() + }) + + it('refuses an edge id another workflow owns', async () => { + mocks.prepare.mockReturnValue({ + state: { ...PREPARED, edges: [EDGE] }, + warnings: [], + }) + queueTableRows(schemaMock.workflowEdges, [{ id: 'edge-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Edge ids already used by another workflow: edge-1', + }) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowEdges.id, ['edge-1']) + expect(ne).toHaveBeenCalledWith(schemaMock.workflowEdges.workflowId, 'workflow-1') + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** + * `workflow_subflows` has its own global primary key, so a value free as a + * block id can still be taken as a subflow id — reported under its own + * label rather than folded into the block families. + */ + it('reports a subflow id collision under its own label', async () => { + mocks.prepare.mockReturnValue({ + state: { + blocks: { 'block-1': BLOCK, 'loop-1': LOOP_BLOCK }, + edges: [], + loops: { 'loop-1': { id: 'loop-1' } }, + parallels: {}, + }, + warnings: [], + }) + queueTableRows(schemaMock.workflowBlocks, []) + queueTableRows(schemaMock.workflowSubflows, [{ id: 'loop-1' }]) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + message: 'Subflow ids already used by another workflow: loop-1', + }) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowSubflows.id, ['loop-1']) + expect(mocks.save).not.toHaveBeenCalled() + }) + + /** Ids only — never the workflow or workspace that holds them. */ + it('names the offending ids and nothing about their owner', async () => { + queueTableRows(schemaMock.workflowBlocks, [{ id: 'block-1' }]) + + const error = await replaceWorkflowNormalizedState(input()).catch((thrown) => thrown) + + expect(error).toMatchObject({ code: 'conflict' }) + expect(error.message).toContain('block-1') + expect(error.message).not.toMatch(/workspace|workflow-2/) + }) + + /** + * The pre-check is the good-message path, not the correctness boundary: the + * `FOR UPDATE` locks the target workflow row, not the workflow that would + * claim the id, so two concurrent writes carrying the same fresh id can + * both pass it under READ COMMITTED. The catch is what keeps that race a + * 409 rather than an unclassified 500. + */ + it('re-classifies a 23505 that races past the pre-check', async () => { + mocks.save.mockRejectedValue( + Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: 'workflow_edges_pkey', + }) + ) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ + code: 'conflict', + }) + }) + + it('leaves an unrelated database fault unclassified', async () => { + const failure = Object.assign(new Error('deadlock detected'), { code: '40P01' }) + mocks.save.mockRejectedValue(failure) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) + }) + }) + /** * The lock predicate is scoped, not just `id`: a workflow archived between the * caller's authorization check and this write is refused rather than written, diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 138616780c1..8aed3125c64 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' +import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' @@ -22,6 +22,151 @@ export class WorkflowStatePersistenceError extends Error { } } +/** The three global-primary-key id families one normalized write inserts. */ +export interface WorkflowGraphIds { + blockIds: string[] + edgeIds: string[] + subflowIds: string[] +} + +/** + * The ids a write of `state` would insert. + * + * Derived from the prepared state rather than from the caller's body, and read + * by both the dry-run preview and the committed write, so the two cannot + * disagree about what is about to be claimed. + * + * Subflow ids are collected separately even though they are container block + * ids: `workflow_subflows` has its own global primary key, so the same value + * can be free as a block id and taken as a subflow id. + */ +export function collectWorkflowGraphIds(state: PreparedWorkflowState): WorkflowGraphIds { + return { + blockIds: Object.keys(state.blocks), + edgeIds: state.edges.map((edge) => edge.id), + subflowIds: [...Object.keys(state.loops), ...Object.keys(state.parallels)], + } +} + +/** How many offending ids a conflict message names before it summarizes the rest. */ +const MAX_REPORTED_CONFLICT_IDS = 5 + +/** + * Renders the offending ids, and only the ids. + * + * Never the workflow or workspace that holds them: the caller supplied these + * values, so naming them back discloses nothing they did not already have, + * where naming the owner would. + */ +function describeClaimedIds(ids: string[]): string { + const sorted = [...ids].sort() + const shown = sorted.slice(0, MAX_REPORTED_CONFLICT_IDS).join(', ') + const remaining = sorted.length - MAX_REPORTED_CONFLICT_IDS + return remaining > 0 ? `${shown} (and ${remaining} more)` : shown +} + +/** + * Refuses a graph whose ids are already owned by a different workflow. + * + * `workflow_blocks.id`, `workflow_edges.id`, and `workflow_subflows.id` are + * global primary keys, but the replace deletes only the rows scoped to this + * workflow — so an id owned elsewhere survives the delete and the insert faults + * on it. Detecting it here turns a caller-reachable 500 into a 409 that names + * what to change. + * + * The `ne(workflowId)` half is what keeps the ordinary round-trip working: ids + * this workflow already holds are deleted before the insert, so they are not a + * conflict. + */ +export async function assertWorkflowGraphIdsUnclaimed( + executor: DbOrTx, + workflowId: string, + ids: WorkflowGraphIds +): Promise { + const [blocks, edges, subflows] = await Promise.all([ + ids.blockIds.length > 0 + ? executor + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where( + and(inArray(workflowBlocks.id, ids.blockIds), ne(workflowBlocks.workflowId, workflowId)) + ) + : Promise.resolve([] as { id: string }[]), + ids.edgeIds.length > 0 + ? executor + .select({ id: workflowEdges.id }) + .from(workflowEdges) + .where( + and(inArray(workflowEdges.id, ids.edgeIds), ne(workflowEdges.workflowId, workflowId)) + ) + : Promise.resolve([] as { id: string }[]), + ids.subflowIds.length > 0 + ? executor + .select({ id: workflowSubflows.id }) + .from(workflowSubflows) + .where( + and( + inArray(workflowSubflows.id, ids.subflowIds), + ne(workflowSubflows.workflowId, workflowId) + ) + ) + : Promise.resolve([] as { id: string }[]), + ]) + + const conflicts: string[] = [] + if (blocks.length > 0) { + conflicts.push( + `Block ids already used by another workflow: ${describeClaimedIds(blocks.map((row) => row.id))}` + ) + } + if (edges.length > 0) { + conflicts.push( + `Edge ids already used by another workflow: ${describeClaimedIds(edges.map((row) => row.id))}` + ) + } + if (subflows.length > 0) { + conflicts.push( + `Subflow ids already used by another workflow: ${describeClaimedIds(subflows.map((row) => row.id))}` + ) + } + + if (conflicts.length > 0) { + throw new OrchestrationError('conflict', conflicts.join('; ')) + } +} + +/** Postgres SQLSTATE for a unique-constraint violation. */ +const UNIQUE_VIOLATION = '23505' + +const GRAPH_ID_CONSTRAINTS = [ + 'workflow_blocks_pkey', + 'workflow_edges_pkey', + 'workflow_subflows_pkey', +] as const + +/** + * Whether a driver fault is another workflow having claimed one of these ids. + * + * The pre-check is the good-message path, not the correctness boundary: the + * row lock covers the target workflow, not the workflow that would claim an id, + * so under READ COMMITTED two concurrent writes carrying the same fresh id can + * both pass the check and one insert still faults. This keeps that race a 409. + */ +function isGraphIdUniqueViolation(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const candidate = error as { + code?: unknown + constraint_name?: unknown + constraint?: unknown + message?: unknown + } + if (candidate.code !== UNIQUE_VIOLATION) return false + const parts = [candidate.constraint_name, candidate.constraint, candidate.message].filter( + (part): part is string => typeof part === 'string' + ) + return parts.some((part) => GRAPH_ID_CONSTRAINTS.some((name) => part.includes(name))) +} + export interface ReplaceWorkflowState { blocks: Record edges: WorkflowState['edges'] @@ -121,7 +266,20 @@ export async function replaceWorkflowNormalizedState( deployedAt: resolved.deployedAt, } as WorkflowState - const result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + await assertWorkflowGraphIdsUnclaimed(tx, workflowId, collectWorkflowGraphIds(prepared.state)) + + let result: Awaited> + try { + result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + } catch (error) { + if (isGraphIdUniqueViolation(error)) { + throw new OrchestrationError( + 'conflict', + 'Another workflow claimed one of the submitted block, edge, or subflow ids while this write was in flight' + ) + } + throw error + } if (!result.success) return result const updateData: Partial = { From 5396edbb4ee2e8ad95bd3a230e54f71767fadd9f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:03:45 -0700 Subject: [PATCH 05/15] fix(secrets): let a workspace secret change its metadata without resending the value Restoring redaction cost more than removing it. The only way to flip a secret back to redacted was to re-send the plaintext, because the write required a value and omitting it fell into an interactive prompt that cannot run in CI. A workspace secret can now change its description or visibility on its own; the stored value is never re-encrypted or rewritten, a write that names no existing secret answers not-found rather than creating one, and a personal secret still requires a value because it has no other writable field. The path parameter was also one shared schema across the write and the delete, so a single description had to cover both and the delete documented an argument that could create and replace. Split, mirroring the credentials pair. The metadata write is a new update against the credentials table, so its scope is asserted by composition and by condition count: an unscoped update would let one workspace flip another workspace's identically-named secret out of redaction, and the cache invalidation would then carry that flag into the other workspace's runtime catalog. --- .../app/api/v2/secrets/[name]/route.test.ts | 53 ++++++++ apps/sim/app/api/v2/secrets/[name]/route.ts | 5 +- .../lib/api/contracts/v2/openapi/resources.ts | 14 +- apps/sim/lib/api/contracts/v2/secrets.test.ts | 94 +++++++++++++ apps/sim/lib/api/contracts/v2/secrets.ts | 82 +++++++++--- .../sim/lib/credentials/secret-values.test.ts | 124 +++++++++++++++++- apps/sim/lib/credentials/secret-values.ts | 47 +++++++ .../lib/secrets/application/use-cases.test.ts | 103 +++++++++++++++ apps/sim/lib/secrets/application/use-cases.ts | 45 ++++++- 9 files changed, 544 insertions(+), 23 deletions(-) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index 19a7cd79edf..74197f93dca 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -206,6 +206,59 @@ describe('/api/v2/secrets/[name]', () => { expect(response.status).toBe(200) }) + it('sends a value-less workspace write through as a metadata-only update at 200', async () => { + mocks.set.mockResolvedValueOnce({ secret, userId: 'user-1', created: false }) + + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', unredacted: false }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.set).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: SECRET_NAME, + scope: 'workspace', + unredacted: false, + }, + request: expect.anything(), + }) + expect(mocks.set.mock.calls[0][0].input).not.toHaveProperty('value') + }) + + it('answers 404 rather than creating when a metadata-only write names no secret', async () => { + mocks.set.mockRejectedValueOnce(new OrchestrationError('not_found', 'Secret not found')) + + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', unredacted: true }), + context + ) + + expect(response.status).toBe(404) + }) + + it('rejects a value-less personal write, which has no metadata field to update', async () => { + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'personal' }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.set).not.toHaveBeenCalled() + }) + + it('rejects a workspace write carrying nothing to write', async () => { + const response = await PUT( + request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace' }), + context + ) + + expect(response.status).toBe(400) + expect(mocks.set).not.toHaveBeenCalled() + }) + it('deletes a secret through the semantic delete operation', async () => { const response = await DELETE(request('DELETE'), context) diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index 35cfd3d42f4..385274a7ac9 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -12,7 +12,10 @@ import { toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** PUT /api/v2/secrets/[name] — Create or replace a write-only secret value. */ +/** + * PUT /api/v2/secrets/[name] — Create or replace a write-only secret value, or + * update a workspace secret's metadata alone when the body carries no value. + */ export const PUT = defineV2JsonRoute({ contract: v2SetSecretContract, operation: secretOperations.set, diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 0f822354b6e..c3370fab825 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1368,11 +1368,14 @@ const declaredRoutes = [ resourceOperation('Secrets', { operationId: 'setSecret', summary: 'Set Secret', - description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. ${WORKSPACE_API_KEY_DENIED}`, + description: `Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit \`value\` on a workspace secret to update \`description\` and \`unredacted\` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers \`404\` when the named secret does not exist. A personal secret always requires \`value\`, having no other writable field. ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { byStatus: { - 200: { description: 'The existing secret value was replaced.' }, + 200: { + description: + 'The existing secret value was replaced, or its metadata was updated in place.', + }, 201: { description: 'The secret was created.' }, }, }, @@ -1389,13 +1392,18 @@ const declaredRoutes = [ v2SetSecretContract.body, 'SetSecretRequest', 'Set secret request', - 'Ownership scope and write-only value for the secret.', + 'Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.', [ { workspaceId: WORKSPACE_ID, scope: SECRET_EXAMPLE.scope, value: 'YOUR_SECRET_VALUE', }, + { + workspaceId: WORKSPACE_ID, + scope: 'workspace', + unredacted: false, + }, ] ), response: documentedSchema( diff --git a/apps/sim/lib/api/contracts/v2/secrets.test.ts b/apps/sim/lib/api/contracts/v2/secrets.test.ts index f6c5cfdc39d..34f08ee9ab0 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.test.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.test.ts @@ -3,9 +3,11 @@ */ import { describe, expect, it } from 'vitest' import { + v2DeleteSecretContract, v2SecretSchema, v2SecretWithValueSchema, v2SetSecretBodySchema, + v2SetSecretContract, } from '@/lib/api/contracts/v2/secrets' const secret = { @@ -70,3 +72,95 @@ describe('v2SecretWithValueSchema value', () => { ).toBe(true) }) }) + +/** Reads the `name` field description off a contract's path-parameter schema. */ +function nameDescription(params: unknown): string | undefined { + const shape = (params as { shape: Record }).shape + return shape.name.description +} + +describe('secret path-parameter descriptions', () => { + it('does not offer writes on the delete path parameter', () => { + const description = nameDescription(v2DeleteSecretContract.params) + + expect(description).toBe('Secret to delete.') + expect(description).not.toMatch(/create|replace/i) + }) + + it('does not offer deletion on the set path parameter', () => { + expect(nameDescription(v2SetSecretContract.params)).not.toMatch(/delete/i) + }) + + it('gives the two operations distinct path-parameter prose', () => { + expect(nameDescription(v2SetSecretContract.params)).not.toBe( + nameDescription(v2DeleteSecretContract.params) + ) + }) +}) + +describe('v2SetSecretBodySchema metadata-only write', () => { + it('accepts a workspace body carrying only unredacted, so restoring redaction costs no value', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + unredacted: false, + }) + + expect(parsed.success).toBe(true) + if (parsed.success) expect(parsed.data.value).toBeUndefined() + }) + + it('accepts a workspace body carrying only a description', () => { + expect( + v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + description: 'Prod billing key', + }).success + ).toBe(true) + }) + + it('rejects a workspace body with nothing to write rather than resolving to an empty update', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual([ + expect.objectContaining({ + path: ['value'], + message: 'value, description, or unredacted is required', + }), + ]) + } + }) + + it('still requires a value for a personal secret, which has no metadata field to write', () => { + const parsed = v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'personal', + }) + + expect(parsed.success).toBe(false) + if (!parsed.success) { + expect(parsed.error.issues).toEqual([ + expect.objectContaining({ + path: ['value'], + message: 'value is required for a personal secret', + }), + ]) + } + }) + + it('keeps rejecting an empty value, which is a write and not an omission', () => { + expect( + v2SetSecretBodySchema.safeParse({ + workspaceId: 'workspace-1', + scope: 'workspace', + value: '', + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/secrets.ts b/apps/sim/lib/api/contracts/v2/secrets.ts index a287a895ffe..bc25ae81d82 100644 --- a/apps/sim/lib/api/contracts/v2/secrets.ts +++ b/apps/sim/lib/api/contracts/v2/secrets.ts @@ -104,10 +104,22 @@ export const v2ListSecretsQuerySchema = z .strict() export type V2ListSecretsQuery = z.output -export const v2SecretParamsSchema = z.object({ - name: v2SecretNameSchema.describe('Secret to create, replace, or delete.'), +/** + * The secret a path addresses, named for what the route does to it. + * + * `PUT` and `DELETE` sit on the same path but are not the same operation, and the + * OpenAPI document already publishes them as two components (`SetSecretParams`, + * `DeleteSecretParams`). One shared `describe()` forced both to read "create, + * replace, or delete", so `sim secrets delete` documented writes the route cannot + * perform. + */ +export const v2SetSecretParamsSchema = z.object({ + name: v2SecretNameSchema.describe('Secret to create or replace.'), +}) + +export const v2DeleteSecretParamsSchema = z.object({ + name: v2SecretNameSchema.describe('Secret to delete.'), }) -export type V2SecretParams = z.output export const v2SetSecretBodySchema = z .object({ @@ -119,7 +131,10 @@ export const v2SetSecretBodySchema = z .string() .min(1, 'value is required') .max(65_536, 'value is too long') - .describe('Write-only secret value. It is never returned.') + .optional() + .describe( + 'Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.' + ) .meta({ writeOnly: true }), description: z .string() @@ -137,19 +152,49 @@ export const v2SetSecretBodySchema = z ), }) .strict() + /** + * `value` is optional on the schema so a workspace secret's redaction policy can + * be flipped back without re-transmitting the plaintext — restoring redaction is + * the safe direction and must not cost more than leaving it off. Two refinements + * keep that from over-relaxing the request: a personal secret has no metadata + * field at all, so a value-less personal write would be a silent no-op rather + * than an update; and a body carrying none of the three writable fields is + * rejected outright instead of resolving to an empty write. + */ .superRefine((data, ctx) => { - if (data.scope === 'personal' && data.description !== undefined) { - ctx.addIssue({ - code: 'custom', - path: ['description'], - message: 'description is only supported for a workspace secret', - }) + if (data.scope === 'personal') { + if (data.value === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['value'], + message: 'value is required for a personal secret', + }) + } + if (data.description !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['description'], + message: 'description is only supported for a workspace secret', + }) + } + if (data.unredacted !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['unredacted'], + message: 'unredacted is only supported for a workspace secret', + }) + } + return } - if (data.scope === 'personal' && data.unredacted !== undefined) { + if ( + data.value === undefined && + data.description === undefined && + data.unredacted === undefined + ) { ctx.addIssue({ code: 'custom', - path: ['unredacted'], - message: 'unredacted is only supported for a workspace secret', + path: ['value'], + message: 'value, description, or unredacted is required', }) } }) @@ -180,12 +225,17 @@ export const v2ListSecretsContract = defineRouteContract({ }, }) -/** Creates or replaces a secret value without returning it. */ +/** + * Creates or replaces a secret value without returning it, or — for a workspace + * secret sent without a value — updates its description and redaction policy + * alone. A value-less write never creates: it answers 404 when the secret is + * absent. + */ export const v2SetSecretContract = defineRouteContract({ method: 'PUT', path: '/api/v2/secrets/[name]', query: noInputSchema, - params: v2SecretParamsSchema, + params: v2SetSecretParamsSchema, body: v2SetSecretBodySchema, response: { mode: 'json', @@ -197,7 +247,7 @@ export const v2SetSecretContract = defineRouteContract({ export const v2DeleteSecretContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/secrets/[name]', - params: v2SecretParamsSchema, + params: v2DeleteSecretParamsSchema, query: v2DeleteSecretQuerySchema, response: { mode: 'json', diff --git a/apps/sim/lib/credentials/secret-values.test.ts b/apps/sim/lib/credentials/secret-values.test.ts index 2e8239168c3..eb4114439e3 100644 --- a/apps/sim/lib/credentials/secret-values.test.ts +++ b/apps/sim/lib/credentials/secret-values.test.ts @@ -1,7 +1,14 @@ /** * @vitest-environment node */ -import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { + dbChainMockFns, + flattenMockConditions, + type MockCondition, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -42,6 +49,7 @@ import { readWorkspaceSecretValues, setPersonalSecret, setWorkspaceSecret, + updateWorkspaceSecretMetadata, } from '@/lib/credentials/secret-values' describe('secret value storage', () => { @@ -281,3 +289,117 @@ describe('readWorkspaceSecretValues', () => { expect(mockDecryptSecret).not.toHaveBeenCalled() }) }) + +/** + * The row-queue mocks resolve whatever was queued regardless of the predicate, so + * the only way to pin a WHERE clause is to read the condition tree the `eq`/`and` + * mocks recorded. An unscoped metadata UPDATE would let any workspace flip another + * workspace's secret out of redaction by name, and the cache invalidation would + * then push that flag into the other workspace's runtime redaction catalog — so the + * count is asserted alongside the triple: a dropped condition is exactly the shape + * a "contains" check alone would let through. + */ +function updateConditions(): MockCondition[] { + const call = dbChainMockFns.where.mock.calls.at(-1) + return flattenMockConditions(call?.[0]) +} + +describe('workspace secret metadata updates', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('scopes the update to this workspace, the env_workspace type, and the named key alone', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + const conditions = updateConditions() + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.workspaceId, + right: 'workspace-1', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.type, + right: 'env_workspace', + }) + expect(conditions).toContainEqual({ + type: 'eq', + left: schemaMock.credential.envKey, + right: 'STRIPE_KEY', + }) + expect(conditions).toHaveLength(3) + }) + + it('writes the metadata without encrypting anything or rewriting the variables map', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + const result = await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + expect(result).toMatchObject({ created: false, updatedAt: expect.any(Date) }) + expect(mockEncryptSecret).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.values).not.toHaveBeenCalled() + expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled() + + const written = dbChainMockFns.set.mock.calls[0][0] as Record + expect(written.unredacted).toBe(false) + expect(written).not.toHaveProperty('description') + expect(written).not.toHaveProperty('variables') + }) + + it('leaves an omitted field alone rather than clearing it', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + description: null, + }) + + const written = dbChainMockFns.set.mock.calls[0][0] as Record + expect(written.description).toBeNull() + expect(written).not.toHaveProperty('unredacted') + }) + + it('invalidates the decrypted env cache, since unredacted rides the run redaction catalog', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + await updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'STRIPE_KEY', + unredacted: false, + }) + + expect(mockInvalidateEffectiveDecryptedEnvCache).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + }) + }) + + it('reports a miss instead of creating a secret, and leaves the cache alone', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + updateWorkspaceSecretMetadata({ + workspaceId: 'workspace-1', + name: 'ABSENT_KEY', + unredacted: true, + }) + ).resolves.toBeNull() + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockCreateWorkspaceEnvCredentials).not.toHaveBeenCalled() + expect(mockInvalidateEffectiveDecryptedEnvCache).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/secret-values.ts b/apps/sim/lib/credentials/secret-values.ts index 732bff1702b..aae67ca55a6 100644 --- a/apps/sim/lib/credentials/secret-values.ts +++ b/apps/sim/lib/credentials/secret-values.ts @@ -143,6 +143,53 @@ export async function setWorkspaceSecret(params: { return { created, updatedAt } } +/** + * Updates one workspace secret's metadata, leaving its stored value untouched. + * + * Deliberately UPDATE-only: it never encrypts, never rewrites the environment + * variables map, and never inserts a credential row, so restoring redaction on a + * secret costs nothing and a metadata write can never conjure a secret that does + * not exist. A write that matches no row returns `null` and the caller answers + * not-found rather than creating one. + * + * The decrypted-env cache is still invalidated on a match: `unredacted` rides the + * environment snapshot into every run's redaction catalog, so a stale entry would + * keep printing a value the workspace just re-redacted. + */ +export async function updateWorkspaceSecretMetadata(params: { + workspaceId: string + name: string + /** `undefined` leaves any existing description untouched; `null` clears it. */ + description?: string | null + /** `undefined` leaves the current setting. */ + unredacted?: boolean +}): Promise { + const { workspaceId, name, description, unredacted } = params + const updatedAt = new Date() + + const updated = await db + .update(credential) + .set({ + updatedAt, + ...(description !== undefined ? { description } : {}), + ...(unredacted !== undefined ? { unredacted } : {}), + }) + .where( + and( + eq(credential.workspaceId, workspaceId), + eq(credential.type, 'env_workspace'), + eq(credential.envKey, name) + ) + ) + .returning({ id: credential.id }) + + if (updated.length === 0) return null + + invalidateEffectiveDecryptedEnvCache({ workspaceId }) + + return { created: false, updatedAt } +} + /** Stores one caller-owned personal secret without decrypting any existing value. */ export async function setPersonalSecret(params: { userId: string diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index 1cd212e25a1..e9519ea0807 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -17,6 +17,7 @@ const { mocks } = vi.hoisted(() => ({ keyAccess: vi.fn(), personalMetadata: vi.fn(), setWorkspace: vi.fn(), + updateWorkspaceMetadata: vi.fn(), setPersonal: vi.fn(), deletePersonal: vi.fn(), listCredentials: vi.fn(), @@ -67,6 +68,7 @@ vi.mock('@/lib/credentials/secret-values', () => ({ readWorkspaceSecretValues: mocks.readWorkspaceValues, setPersonalSecret: mocks.setPersonal, setWorkspaceSecret: mocks.setWorkspace, + updateWorkspaceSecretMetadata: mocks.updateWorkspaceMetadata, })) import { @@ -130,6 +132,10 @@ describe('secret application use cases', () => { mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) mocks.keyAccess.mockResolvedValue({ knownKeys: new Set(), adminKeys: new Set() }) mocks.setWorkspace.mockResolvedValue({ created: true, updatedAt: personalUpdatedAt }) + mocks.updateWorkspaceMetadata.mockResolvedValue({ + created: false, + updatedAt: personalUpdatedAt, + }) mocks.setPersonal.mockResolvedValue({ created: true, updatedAt: personalUpdatedAt }) mocks.personalMetadata.mockResolvedValue(null) mocks.deletePersonal.mockResolvedValue(true) @@ -444,6 +450,103 @@ describe('secret application use cases', () => { expect(mocks.personalMetadata).not.toHaveBeenCalled() }) + it('updates workspace metadata through the update-only manager, never re-encrypting the value', async () => { + const result = await setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: false, + }, + }) + + expect(mocks.updateWorkspaceMetadata).toHaveBeenCalledWith({ + workspaceId: workspace.workspaceId, + name: secret.envKey, + description: undefined, + unredacted: false, + }) + expect(mocks.setWorkspace).not.toHaveBeenCalled() + expect(mocks.setPersonal).not.toHaveBeenCalled() + expect(result.created).toBe(false) + }) + + it('still checks the per-key ACL before a metadata-only write', async () => { + mocks.workspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, canAdmin: false }) + mocks.keyAccess.mockResolvedValue({ + knownKeys: new Set([secret.envKey]), + adminKeys: new Set(), + }) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toThrow(/Credential admin permission required/) + + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + }) + + it('reports a metadata write against a missing secret as not found rather than creating one', async () => { + mocks.updateWorkspaceMetadata.mockResolvedValue(null) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.setWorkspace).not.toHaveBeenCalled() + }) + + it('refuses a value-less personal write in the use case, not just the contract', async () => { + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: personalSecret.envKey, + scope: 'personal', + }, + }) + ).rejects.toThrow(/value is required for a personal secret/) + + expect(mocks.setPersonal).not.toHaveBeenCalled() + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + }) + + it('audits a metadata-only write as an update rather than as setting a value', async () => { + await setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: false, + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Updated workspace secret "${secret.envKey}" metadata`, + metadata: expect.objectContaining({ unredacted: false, operation: 'secrets.set' }), + }) + ) + }) + it('deletes a personal secret for the caller rather than for one workspace', async () => { const execute = deleteSecretUseCase.execute as (args: { principal: Principal diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 1f5b196c629..495acf7f17e 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -19,6 +19,7 @@ import { readWorkspaceSecretValues, setPersonalSecret, setWorkspaceSecret, + updateWorkspaceSecretMetadata, } from '@/lib/credentials/secret-values' import { secretOperations } from '@/lib/secrets/application/operations' import { scanSecretReferences } from '@/lib/secrets/references/scan' @@ -268,7 +269,12 @@ export interface SetSecretInput { workspaceId: string name: string scope: SecretScope - value: string + /** + * Omitted for a workspace-scope metadata-only write, which updates `description` + * and `unredacted` alone and never re-encrypts or replaces the stored value. + * Required for personal scope, which has no other writable field. + */ + value?: string /** * Workspace scope only, and rejected at the contract for personal scope: an * `env_personal` row is a per-workspace mirror of one user-global secret, so a @@ -307,6 +313,29 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ } if (input.scope === 'workspace') { + /** + * A value-less workspace write takes the update-only manager: no encryption, + * no variables rewrite, and no credential insert, so it cannot create a + * secret and cannot cost the caller a re-transmission of the plaintext. A + * miss is a 404, and `created: false` keeps the route answering 200 for + * something it did not create. + */ + if (input.value === undefined) { + const metadata = await updateWorkspaceSecretMetadata({ + workspaceId: context.workspaceId, + name: input.name, + description: input.description, + unredacted: input.unredacted, + }) + if (!metadata) throw new OrchestrationError('not_found', 'Secret not found') + const updated = await getWorkspaceSecretMetadata({ + workspaceId: context.workspaceId, + userId, + name: input.name, + }) + return { secret: updated, userId, created: false } + } + const mutation = await setWorkspaceSecret({ workspaceId: context.workspaceId, name: input.name, @@ -323,6 +352,15 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ return { secret, userId, created: mutation.created } } + /** + * Personal scope has no metadata field, so a value-less write here could only be + * a silent no-op. The contract rejects it; this repeats the guard for every + * other surface that reaches the use case directly. + */ + if (input.value === undefined) { + throw new OrchestrationError('validation', 'value is required for a personal secret') + } + const mutation = await setPersonalSecret({ userId, name: input.name, value: input.value }) const secret = await getPersonalSecretMetadata({ workspaceId: context.workspaceId, @@ -337,7 +375,10 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ resourceType: AuditResourceType.ENVIRONMENT, resourceId: `${input.scope}:${input.name}`, resourceName: input.name, - description: `Set ${input.scope} secret "${input.name}"`, + description: + input.value === undefined + ? `Updated ${input.scope} secret "${input.name}" metadata` + : `Set ${input.scope} secret "${input.name}"`, metadata: { scope: input.scope, name: input.name, From 713384546a1364d5f730d2cf8f0d5ce11d1c8f77 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:08:52 -0700 Subject: [PATCH 06/15] fix(v2): say what an error means in terms the caller can act on A size-limit refusal collapsed every value under a kilobyte to "0 Bytes", so a 28-byte file over a 27-byte ceiling read "is 0 Bytes, above the 0 Bytes limit" - self-contradictory, and useless for choosing a value that would work. Errors and field descriptions also told callers to invoke raw HTTP endpoints. These strings serve the REST reference and the CLI's own help equally, so they now name the operation and its object rather than a method and a path. A sweep test walks every v2 schema description and holds the line, with the remaining offenders in files this change does not own recorded explicitly rather than left to be rediscovered. Listing the editors of a built-in skill claimed the skill did not exist, while reading the same id succeeded - a well-formed request for a real resource is not malformed, so the list answers an empty roster and only the mutations refuse. Bulk folder deletion recorded only the leaf name in its audit trail while the single delete recorded the full path, leaving two same-named folders under different parents indistinguishable after the fact. Bulk chunk enable, disable and delete each treated an unmatched id differently behind one sentence of documentation. They now follow one rule. A workspace-scoped list refused with the name of a resource the caller never addressed, which reads as an empty workspace rather than an unreachable one. --- .../app/api/v2/chat-deployments/route.test.ts | 11 +- apps/sim/app/api/v2/chat-deployments/route.ts | 4 +- apps/sim/app/api/v2/chat-deployments/utils.ts | 10 + .../transport-neutral-descriptions.test.ts | 183 ++++++++++++++++++ apps/sim/lib/api/contracts/v2/catalog.ts | 6 +- apps/sim/lib/api/contracts/v2/files.ts | 12 +- .../api/contracts/v2/knowledge-chunks.test.ts | 20 ++ .../lib/api/contracts/v2/knowledge-chunks.ts | 13 +- apps/sim/lib/api/contracts/v2/tables.ts | 6 +- .../api/contracts/v2/workflow-mcp-servers.ts | 2 +- apps/sim/lib/knowledge/chunks/service.test.ts | 45 ++++- apps/sim/lib/knowledge/chunks/service.ts | 24 ++- .../sim/lib/mcp/application/use-cases.test.ts | 18 +- apps/sim/lib/mcp/application/use-cases.ts | 2 +- .../application/editor-use-cases.test.ts | 65 ++++++- apps/sim/lib/skills/application/use-cases.ts | 34 +++- apps/sim/lib/table/application/bulk.test.ts | 77 ++++++++ apps/sim/lib/table/application/bulk.ts | 58 +++--- .../read-workspace-file-text.test.ts | 48 ++++- .../application/read-workspace-file-text.ts | 11 +- .../resolve-rendered-workspace-artifact.ts | 2 +- 21 files changed, 584 insertions(+), 67 deletions(-) create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts diff --git a/apps/sim/app/api/v2/chat-deployments/route.test.ts b/apps/sim/app/api/v2/chat-deployments/route.test.ts index afe127de3c1..fa44381d548 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.test.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.test.ts @@ -295,12 +295,21 @@ describe('/api/v2/chat-deployments', () => { expect(response.status).toBe(200) }) - it('conceals a workspace the caller cannot reach as 404', async () => { + /** + * The list addresses a workspace, so the concealed denial must name the + * workspace. Naming a chat deployment reported a resource the caller never + * asked for. Concealment itself is unchanged: still 404, still no signal + * about whether the workspace holds any deployment. + */ + it('conceals a workspace the caller cannot reach as a missing workspace', async () => { mocks.resolvePermission.mockResolvedValue(null) const response = await get() expect(response.status).toBe(404) + const body = await response.json() + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.message).toBe('Workspace not found') expect(mocks.listDeployments).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/chat-deployments/route.ts b/apps/sim/app/api/v2/chat-deployments/route.ts index 660db2aa332..b6e20424d1e 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.ts @@ -3,7 +3,7 @@ import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { chatDeploymentOperations, listChatDeployments } from '@/lib/chat-deployments/application' import { - chatDeploymentErrorPolicy, + chatDeploymentWorkspaceErrorPolicy, toV2ChatDeploymentListItem, } from '@/app/api/v2/chat-deployments/utils' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' @@ -48,7 +48,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: chatDeploymentOperations.list, rateLimit: v2RateLimits.publicApi, - errorPolicy: chatDeploymentErrorPolicy, + errorPolicy: chatDeploymentWorkspaceErrorPolicy, mapInput: ({ query }) => ({ workspaceId: query.workspaceId, workflowId: query.workflowId, diff --git a/apps/sim/app/api/v2/chat-deployments/utils.ts b/apps/sim/app/api/v2/chat-deployments/utils.ts index 160cc4cafef..85226313231 100644 --- a/apps/sim/app/api/v2/chat-deployments/utils.ts +++ b/apps/sim/app/api/v2/chat-deployments/utils.ts @@ -135,3 +135,13 @@ export function toV2ChatDeploymentListItem( export const chatDeploymentErrorPolicy = createV2ResourceConcealmentPolicy({ notFoundMessage: 'Chat deployment not found', }) + +/** + * The list is addressed by workspace, not by deployment, so a concealed + * cross-tenant denial must name the workspace the caller asked for. Concealment + * itself is unchanged — an unreachable workspace still answers 404 whether or + * not it holds any deployment. + */ +export const chatDeploymentWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts new file mode 100644 index 00000000000..b45a7031154 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' + +/** + * Every v2 schema description is user-facing prose on two surfaces at once: the + * published API reference and `sim --help`, which is generated from + * these exact strings. A description spelling an HTTP method and path tells a + * CLI caller to do something the CLI cannot do, so descriptions name the + * operation and its object rather than the transport. + * + * This is a sweep rather than a handful of per-file assertions because the + * strings that regressed last time sat a few lines from ones already fixed by + * hand. Anything deliberately left alone goes in ALLOWED below with its reason, + * and the sweep fails when an allowlisted description no longer appears, so the + * list cannot rot. + * + * Allowlisting is keyed by the description text, not by schema path: these + * schemas are shared between contracts, so one sentence surfaces under many + * paths and fixing it must clear every one of them at once. + */ + +const ENDPOINT_SPELLING = /\b(GET|POST|PATCH|PUT|DELETE)\s+\// + +/** Depth cap so a self-referential `lazy` schema cannot spin the walk. */ +const MAX_DEPTH = 12 + +/** + * Descriptions still naming a transport. Every entry is a contract file owned by + * another change in flight — none is a judgment that the spelling is correct. + */ +const ALLOWED = new Map([ + [ + 'Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read.', + 'knowledge tag contracts owned elsewhere', + ], + [ + 'Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.', + 'knowledge tag contracts owned elsewhere', + ], + [ + 'Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.', + 'knowledge.ts owned elsewhere', + ], + [ + 'ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.', + 'knowledge.ts owned elsewhere', + ], + [ + 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + 'knowledge.ts owned elsewhere', + ], + [ + 'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', + 'knowledge.ts owned elsewhere', + ], + [ + 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.', + 'workflows.ts owned elsewhere', + ], + [ + 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back.', + 'workflows.ts owned elsewhere', + ], + [ + 'Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`.', + 'workflows.ts owned elsewhere', + ], + [ + 'Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.', + 'workflows.ts owned elsewhere', + ], + ['Custom tool id returned by `GET /api/v2/custom-tools`.', 'workflows.ts owned elsewhere'], + [ + 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`.', + 'workflows.ts owned elsewhere', + ], +]) + +interface Described { + /** `file.ts#exportName.field`, so a failure names the symbol to edit. */ + key: string + description: string +} + +function describedOf(node: unknown): string | undefined { + const described = node as { description?: unknown; meta?: () => { description?: unknown } } + if (typeof described?.description === 'string') return described.description + const meta = typeof described?.meta === 'function' ? described.meta() : undefined + return typeof meta?.description === 'string' ? meta.description : undefined +} + +function collect(node: unknown, key: string, seen: Set, out: Described[], depth: number) { + if (!node || typeof node !== 'object' || depth <= 0 || seen.has(node)) return + seen.add(node) + const def = (node as { def?: Record }).def + if (!def) return + + const description = describedOf(node) + if (description) out.push({ key, description }) + + for (const wrapper of ['innerType', 'in', 'out', 'schema', 'element', 'valueType', 'keyType']) { + if (def[wrapper]) collect(def[wrapper], key, seen, out, depth - 1) + } + for (const option of (def.options as unknown[] | undefined) ?? []) { + collect(option, key, seen, out, depth - 1) + } + for (const [field, child] of Object.entries( + (def.shape as Record | undefined) ?? {} + )) { + collect(child, `${key}.${field}`, seen, out, depth - 1) + } +} + +/** Every description reachable from an exported schema or route contract. */ +async function sweepDescriptions(): Promise { + const out: Described[] = [] + for (const file of listContractFiles().filter((path) => path.includes('/contracts/v2/'))) { + const name = file.split('/contracts/v2/')[1] + const module = (await import(file)) as Record + for (const [exported, value] of Object.entries(module)) { + if (!value || typeof value !== 'object') continue + /** + * A fresh visited set per export: schemas are shared between contracts, and + * deduplicating across them would report a shared field under whichever + * export reached it first and hide the rest. + */ + const seen = new Set() + const key = `${name}#${exported}` + if ('def' in value) { + collect(value, key, seen, out, MAX_DEPTH) + continue + } + const contract = value as { + params?: unknown + query?: unknown + body?: unknown + headers?: unknown + response?: { schema?: unknown } + } + for (const slot of ['params', 'query', 'body', 'headers'] as const) { + if (contract[slot]) collect(contract[slot], `${key}.${slot}`, seen, out, MAX_DEPTH) + } + if (contract.response?.schema) { + collect(contract.response.schema, `${key}.response`, seen, out, MAX_DEPTH) + } + } + } + return out +} + +function offendingDescriptions(described: Described[]): Map { + const byDescription = new Map() + for (const { key, description } of described) { + if (!ENDPOINT_SPELLING.test(description)) continue + const keys = byDescription.get(description) + if (keys) keys.push(key) + else byDescription.set(description, [key]) + } + return byDescription +} + +describe('v2 schema descriptions', () => { + it('name the operation rather than an HTTP method and path', async () => { + const described = await sweepDescriptions() + expect(described.length).toBeGreaterThan(1000) + + const unexpected = [...offendingDescriptions(described)] + .filter(([description]) => !ALLOWED.has(description)) + .map(([description, keys]) => `${keys[0]} :: ${description}`) + + expect(unexpected).toEqual([]) + }) + + it('keeps the allowlist honest', async () => { + const offending = offendingDescriptions(await sweepDescriptions()) + const stale = [...ALLOWED.keys()].filter((description) => !offending.has(description)) + + expect(stale).toEqual([]) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index cd7cdbfcc25..92c87d21ff3 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -227,13 +227,11 @@ export const v2BlockSummarySchema = z toolIds: z .array(z.string()) .describe( - 'Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`.' + 'Built-in tools this block can run. Read a tool by its id for the full definition.' ), operationIds: z .array(z.string()) - .describe( - 'Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`.' - ), + .describe('Operations this block exposes. Their fields and tools are on the block read.'), preview: z .boolean() .describe('Whether the block is unreleased and revealed only to this caller.'), diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 14165926f55..f07e0557bff 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -72,13 +72,13 @@ export const v2FileSchema = z .number() .nonnegative() .describe( - 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.' + 'Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.' ) .meta({ examples: [1024] }), type: z .string() .describe( - 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.' + 'MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.' ) .meta({ examples: ['text/csv'] }), key: z @@ -108,7 +108,7 @@ export const v2FileSchema = z .string() .nullable() .describe( - 'ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.' + 'ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.' ) .meta({ format: 'date-time', examples: ['2026-01-16T09:00:00Z'] }), }) @@ -396,7 +396,7 @@ export const v2GetFileMetadataQuerySchema = z scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.' + 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.' ), }) .strict() @@ -510,7 +510,7 @@ export const v2ListFileFoldersQuerySchema = v2ListFoldersQuerySchema.extend({ scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.' + 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.' ), }) export type V2ListFileFoldersQuery = z.output @@ -526,7 +526,7 @@ export const v2RestoreFileFolderBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), path: v2NonRootFolderPathInputSchema.describe( - 'Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.' + 'Path of the archived folder to restore, as reported by an archived-scope folder list.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts new file mode 100644 index 00000000000..22f5842039c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { v2BulkKnowledgeChunksBodySchema } from '@/lib/api/contracts/v2/knowledge-chunks' + +/** + * This description is the only source of the `--chunk` flag help the CLI + * generates, so it and the implementation must state the same rule. It used to + * say unmatched ids were "ignored" while the response returned them in + * `errors[]` — for two of the three operations. + */ +describe('v2 bulk knowledge chunk contract', () => { + const description = v2BulkKnowledgeChunksBodySchema.shape.chunkIds.description ?? '' + + it('does not promise unmatched ids are ignored', () => { + expect(description).not.toMatch(/ignored/i) + }) + + it('points at the field that reports unmatched ids', () => { + expect(description).toMatch(/errors/) + }) +}) diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts index 6acc5d22138..653270c27df 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts @@ -202,15 +202,18 @@ export const v2BulkKnowledgeChunksBodySchema = z MAX_V2_BULK_KNOWLEDGE_CHUNKS, `chunkIds cannot contain more than ${MAX_V2_BULK_KNOWLEDGE_CHUNKS} chunks` ) - .describe('Chunks to operate on, by identifier. Ids outside the document are ignored.'), + .describe( + 'Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request.' + ), }) .strict() export type V2BulkKnowledgeChunksBody = z.input /** * Bulk chunk outcome. Unlike the per-chunk operations this is best-effort: an - * identifier naming no chunk in the document is skipped rather than failing the - * request, so `processed` is the authoritative count. + * identifier naming no chunk in the document is reported in `errors` rather + * than failing the request, and the same rule holds for all three operations. + * `processed` counts only the chunks that actually changed. */ export const v2BulkKnowledgeChunksDataSchema = z .object({ @@ -223,7 +226,9 @@ export const v2BulkKnowledgeChunksDataSchema = z .meta({ examples: [12] }), errors: z .array(z.string()) - .describe('Per-chunk failures. A populated array still answers 200.'), + .describe( + 'Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200.' + ), }) .strict() .meta({ diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 59e9c970e20..95f1686b33a 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -436,7 +436,7 @@ export const v2ListTablesQuerySchema = z scope: v2TableScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' ), folderPath: v2FolderPathInputSchema .optional() @@ -664,7 +664,7 @@ export const v2RestoreTableFolderBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace that owns the archived folder.'), path: v2NonRootFolderPathInputSchema.describe( - 'Path the folder held when `DELETE /api/v2/tables/folders` archived it.' + 'Path the folder held when a folder delete archived it.' ), }) .strict() @@ -1185,7 +1185,7 @@ export const v2UpsertTableRowBodySchema = upsertTableRowBodySchema .omit(OMIT_PRIVATE_PROVENANCE) .extend({ data: v2RowDataSchema.describe( - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.' + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts index cb92abe2bc8..bd9c071fa45 100644 --- a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts @@ -356,7 +356,7 @@ export const v2ListWorkflowMcpServersContract = defineRouteContract({ toolNamesTruncated: z .boolean() .describe( - "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory for its full set and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." ), }), }, diff --git a/apps/sim/lib/knowledge/chunks/service.test.ts b/apps/sim/lib/knowledge/chunks/service.test.ts index c1bd1a00194..02f431bb013 100644 --- a/apps/sim/lib/knowledge/chunks/service.test.ts +++ b/apps/sim/lib/knowledge/chunks/service.test.ts @@ -2,7 +2,8 @@ * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { embedding } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/knowledge/embeddings', () => ({ @@ -67,4 +68,46 @@ describe('batchChunkOperation', () => { expect(result).toEqual({ success: true, processed: 2, errors: [] }) }) + + /** + * Delete used to answer differently from enable and disable: a partially + * unmatched delete reported no error at all, and an entirely unmatched one + * reported a generic message naming no id. Three rules behind one sentence of + * documentation is what the contract could not describe honestly. + */ + it('reports ids that name no chunk when deleting', async () => { + queueTableRows(embedding, [{ id: 'chunk-1', tokenCount: 5, contentLength: 20 }]) + + const result = await batchChunkOperation( + 'document-1', + 'delete', + ['chunk-1', 'chunk-missing'], + 'request-4' + ) + + expect(result.processed).toBe(1) + expect(result.errors).toEqual(['No matching chunks found to delete: chunk-missing']) + }) + + it('names the missing ids when no chunk matches a delete', async () => { + queueTableRows(embedding, []) + + const result = await batchChunkOperation( + 'document-1', + 'delete', + ['missing-1', 'missing-2'], + 'request-5' + ) + + expect(result.processed).toBe(0) + expect(result.errors).toEqual(['No matching chunks found to delete: missing-1, missing-2']) + }) + + it('reports success with no errors when every requested chunk is deleted', async () => { + queueTableRows(embedding, [{ id: 'chunk-1', tokenCount: 5, contentLength: 20 }]) + + const result = await batchChunkOperation('document-1', 'delete', ['chunk-1'], 'request-6') + + expect(result).toEqual({ success: true, processed: 1, errors: [] }) + }) }) diff --git a/apps/sim/lib/knowledge/chunks/service.ts b/apps/sim/lib/knowledge/chunks/service.ts index 0f61de4cc36..8dd509a5c05 100644 --- a/apps/sim/lib/knowledge/chunks/service.ts +++ b/apps/sim/lib/knowledge/chunks/service.ts @@ -340,6 +340,7 @@ export async function batchChunkOperation( const errors: string[] = [] let successCount = 0 + let matchedIds = new Set() if (operation === 'delete') { // Handle batch delete with transaction for consistency @@ -354,15 +355,13 @@ export async function batchChunkOperation( .from(embedding) .where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds))) - if (chunksToDelete.length === 0) { - errors.push('No matching chunks found to delete') - return - } + matchedIds = new Set(chunksToDelete.map(({ id }) => id)) + if (chunksToDelete.length === 0) return const totalTokensToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.tokenCount, 0) const totalCharsToRemove = chunksToDelete.reduce((sum, chunk) => sum + chunk.contentLength, 0) - const deleteResult = await tx + await tx .delete(embedding) .where(and(eq(embedding.documentId, documentId), inArray(embedding.id, chunkIds))) @@ -390,12 +389,17 @@ export async function batchChunkOperation( .returning({ id: embedding.id }) successCount = updatedChunks.length + matchedIds = new Set(updatedChunks.map(({ id }) => id)) + } - const matchedIds = new Set(updatedChunks.map(({ id }) => id)) - const unmatchedIds = chunkIds.filter((chunkId) => !matchedIds.has(chunkId)) - if (unmatchedIds.length > 0) { - errors.push(`No matching chunks found to ${operation}: ${unmatchedIds.join(', ')}`) - } + /** + * One rule for all three operations: an id naming no chunk in this document + * is reported in `errors[]` and never fails the request, so a caller can tell + * which ids it named were wrong instead of inferring it from `processed`. + */ + const unmatchedIds = chunkIds.filter((chunkId) => !matchedIds.has(chunkId)) + if (unmatchedIds.length > 0) { + errors.push(`No matching chunks found to ${operation}: ${unmatchedIds.join(', ')}`) } logger.info( diff --git a/apps/sim/lib/mcp/application/use-cases.test.ts b/apps/sim/lib/mcp/application/use-cases.test.ts index 9f59727b7a2..d75f79e7ada 100644 --- a/apps/sim/lib/mcp/application/use-cases.test.ts +++ b/apps/sim/lib/mcp/application/use-cases.test.ts @@ -147,6 +147,11 @@ describe('MCP server application use cases', () => { ) }) + /** + * The message reaches the REST API and the CLI alike, so it names the + * operation and the server it collided with rather than an HTTP endpoint only + * one of those two callers can reach. + */ it('rejects an existing live URL before mutation and audit', async () => { mocks.idState.mockResolvedValueOnce({ deleted: false }) @@ -155,7 +160,18 @@ describe('MCP server application use cases', () => { principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, }) - ).rejects.toMatchObject({ code: 'conflict' }) + ).rejects.toMatchObject({ + code: 'conflict', + message: expect.stringContaining('Update that server instead of creating a new one'), + }) + + mocks.idState.mockResolvedValueOnce({ deleted: false }) + await expect( + createMcpServerUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: workspace.workspaceId, name: server.name, url: server.url }, + }) + ).rejects.toMatchObject({ message: expect.not.stringMatching(/\/api\/v2\//) }) expect(mocks.create).not.toHaveBeenCalled() expect(mocks.audit).not.toHaveBeenCalled() diff --git a/apps/sim/lib/mcp/application/use-cases.ts b/apps/sim/lib/mcp/application/use-cases.ts index c0d4a05113e..fd5568ae2da 100644 --- a/apps/sim/lib/mcp/application/use-cases.ts +++ b/apps/sim/lib/mcp/application/use-cases.ts @@ -291,7 +291,7 @@ export const createMcpServerUseCase = defineAuthorizedWorkspaceUseCase({ if (idState && !idState.deleted) { throw new OrchestrationError( 'conflict', - 'An MCP server with this URL already exists in this workspace. Update it with PATCH /api/v2/mcp-servers/{mcpServerId}.' + `An MCP server with this URL already exists in this workspace: ${serverId}. Update that server instead of creating a new one.` ) } let result: PerformMcpServerResult & { server: McpServerRow } diff --git a/apps/sim/lib/skills/application/editor-use-cases.test.ts b/apps/sim/lib/skills/application/editor-use-cases.test.ts index 09cbd9e0a14..d0a49152c31 100644 --- a/apps/sim/lib/skills/application/editor-use-cases.test.ts +++ b/apps/sim/lib/skills/application/editor-use-cases.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ listEditors: vi.fn(), listWorkspaceMembers: vi.fn(), recordAudit: vi.fn(), + getSkillById: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -53,7 +54,7 @@ vi.mock('@/lib/skills/orchestration', () => ({ })) vi.mock('@/lib/workflows/skills/operations', () => ({ - getSkillById: vi.fn(), + getSkillById: mocks.getSkillById, listSkillSummariesPage: vi.fn(), listSkillsForUser: vi.fn(), })) @@ -288,4 +289,66 @@ describe('skill editor application use cases', () => { expect(mocks.loadWorkspace).not.toHaveBeenCalled() expect(mocks.resolvePermission).not.toHaveBeenCalled() }) + + /** + * A built-in skill is materialized from code, so `skills get` and `skills + * list` both return it. Reporting it as missing from the editor verbs + * contradicted that: the read and the writes disagree about what is possible, + * not about what exists. + */ + describe('built-in skills', () => { + const BUILTIN_ID = 'builtin-research' + + beforeEach(() => { + mocks.getSkillById.mockResolvedValue({ ...skillRow, id: BUILTIN_ID }) + }) + + it('lists an empty editor roster rather than refusing the read', async () => { + const result = await listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + sortBy: 'email', + sortOrder: 'asc', + }, + }) + + expect(result).toMatchObject({ editors: [], hasMore: false }) + expect(mocks.listEditors).not.toHaveBeenCalled() + expect(mocks.resolvePermission).toHaveBeenCalled() + }) + + it('refuses a grant as read-only rather than as missing', async () => { + await expect( + grantSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Built-in skills are read-only and cannot be modified', + }) + }) + + it('refuses a revoke as read-only rather than as missing', async () => { + await expect( + revokeSkillEditorUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: BUILTIN_ID, + target: { kind: 'email', email: TARGET_EMAIL }, + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'Built-in skills are read-only and cannot be modified', + }) + }) + }) }) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 65499c817bb..31565116735 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -58,11 +58,24 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } +/** + * Resolves the skill an editor MUTATION addresses. + * + * A built-in skill exists — `getSkillById` materializes it from code — so + * reporting it as missing would contradict the read verbs. It has no editor + * roster to change, which is the same refusal `resolveEditableSkill` gives for + * update and delete. + */ async function resolveSkillEditorContext( skillId: string, assertedWorkspaceId?: string ): Promise { - if (isBuiltinSkillId(skillId)) throw new OrchestrationError('not_found', 'Skill not found') + if (isBuiltinSkillId(skillId)) { + throw new OrchestrationError( + 'validation', + 'Built-in skills are read-only and cannot be modified' + ) + } const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) { @@ -73,6 +86,20 @@ async function resolveSkillEditorContext( return { ...workspace, skill: row } } +/** + * Resolves the skill an editor LIST addresses. + * + * Listing is a read, so a built-in id is not a malformed request: the skill is + * real and `skills get` returns it. It resolves through the workspace the + * caller asserted, and the roster it reports is empty. + */ +async function resolveSkillEditorListContext(input: ListSkillEditorsInput): Promise { + if (isBuiltinSkillId(input.skillId) && input.workspaceId) { + return resolveSkillContext(input.workspaceId, input.skillId) + } + return resolveSkillEditorContext(input.skillId, input.workspaceId) +} + const authorizationOptions = { delegation: skillDelegationPolicy } async function requireSkillEditorAccess(userId: string, context: SkillContext): Promise { @@ -368,9 +395,12 @@ export interface ListSkillEditorsInput { export const listSkillEditorsUseCase = defineAuthorizedWorkspaceUseCase({ operation: skillOperations.listEditors, resolveContext: ({ input }: { input: ListSkillEditorsInput }) => - resolveSkillEditorContext(input.skillId, input.workspaceId), + resolveSkillEditorListContext(input), authorizationOptions, async execute({ input, context }) { + if (isBuiltinSkillId(input.skillId)) { + return { editors: [], hasMore: false, offset: input.offset ?? 0, limit: input.limit ?? 0 } + } const editors = await listSkillEditors({ id: context.skill.id, workspaceId: context.workspaceId, diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts index db7ad035c22..0726111a1ba 100644 --- a/apps/sim/lib/table/application/bulk.test.ts +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -639,6 +639,83 @@ describe('path-keyed bulk table selections', () => { ]) }) + /** + * The v2 audit formatter nulls `resourceId` for every folder row, so the leaf + * name is all a v2 consumer would have left — and two folders named `dup` in + * different trees produce byte-identical rows. The single-folder delete + * records the path for the same reason. + */ + it('records the folder path a path-keyed bulk delete named, as the single delete does', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 0, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(), + }) + + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'paths' as const, + tableIds: [], + folders: ['/Sales'], + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.deleted', + resourceId: 'folder-1', + description: 'Deleted table folder "/Sales"', + metadata: expect.objectContaining({ path: '/Sales' }), + }) + ) + }) + + /** + * The id-keyed surface skips the folder-tree index read on purpose, so it has + * no path to record. Pinned so a later change cannot quietly take that lock. + */ + it('records no path for an id-keyed bulk delete', async () => { + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Sales' }], + failed: [], + folderCount: 1, + resourceCount: 0, + }) + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Sales' }], + notFound: [], + contained: [], + covered: new Set(), + }) + + await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + folderKeying: 'ids' as const, + tableIds: [], + folders: ['folder-1'], + }, + }) + + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'folder.deleted', + description: 'Deleted table folder "Sales"', + metadata: expect.not.objectContaining({ path: expect.anything() }), + }) + ) + }) + /** * One index for the whole batch: `resolveTableFolderPath` takes the folder * tree lock per call, so per-path resolution would be a lock acquisition each. diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index a75c89d3b33..9fd587b4732 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -573,30 +573,40 @@ export const bulkDeleteTables = defineAuthorizedTableUseCase({ * is unbounded, and per-resource entries would let one request write * thousands of audit rows. */ - projectAudit: ({ result }) => - result.auditedDeletions.map((item) => - item.kind === 'folder' - ? { - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: item.id, - resourceName: item.name, - description: `Deleted table folder "${item.name}"`, - metadata: { - folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, - affected: result.deletedItems, - bulk: true, - }, - } - : { - action: AuditAction.TABLE_DELETED, - resourceType: AuditResourceType.TABLE, - resourceId: item.id, - resourceName: item.name, - description: `Archived table "${item.name}"`, - metadata: { bulk: true }, - } - ), + projectAudit: ({ context, result }) => + result.auditedDeletions.map((item) => { + if (item.kind !== 'folder') { + return { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: `Archived table "${item.name}"`, + metadata: { bulk: true }, + } + } + /** + * The v2 audit formatter nulls `resourceId` for every folder row, so the + * caller's own path is the only thing left that distinguishes two + * same-named folders — the single delete records it for the same reason. + * Present only for a path-keyed selection: an id-keyed one deliberately + * skips the folder-tree index read that builds the map. + */ + const path = context.folderPathById?.get(item.id) + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted table folder "${path ?? item.name}"`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + ...(path !== undefined && { path }), + affected: result.deletedItems, + bulk: true, + }, + } + }), afterSuccess: ({ result }) => { rethrowTableBatchTerminalFailure(result) }, diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts index cfdd5757415..641f6390a48 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts @@ -43,6 +43,7 @@ vi.mock('@/lib/file-parsers', () => ({ })) import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' const WORKSPACE_ID = 'workspace-1' @@ -198,15 +199,22 @@ describe('readWorkspaceFileText', () => { expect(result.truncated).toBe(true) }) + /** + * The message is served to raw HTTP, Copilot, and the CLI alike, so it names + * the remedy rather than an endpoint only one of those three can call. + */ it('rejects an unsupported type and names the raw-bytes escape hatch', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'photo.heic' })) + mocks.getFile.mockResolvedValue(fileRecord({ name: 'photo.heic' })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input() }) ).rejects.toMatchObject({ code: 'validation', - message: expect.stringContaining(`GET /api/v2/files/${FILE_ID}`), + message: expect.stringContaining('download the raw bytes'), }) + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input() }) + ).rejects.toMatchObject({ message: expect.not.stringMatching(/\/api\/v2\//) }) expect(mocks.fetchBuffer).not.toHaveBeenCalled() }) @@ -239,6 +247,22 @@ describe('readWorkspaceFileText', () => { ).rejects.toMatchObject({ code: 'payload_too_large' }) }) + /** + * Both numbers are sub-1 KB, which the default size formatting renders as + * "0 Bytes" — leaving the caller unable to work out what to pass instead. + */ + it('names the real size and limit when both are under 1 KB', async () => { + mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 28 })) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 27 }) }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining('is 28 Bytes, above the 27 Bytes'), + }) + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + it('reports a missing file as not found', async () => { mocks.getFile.mockResolvedValueOnce(null) @@ -319,4 +343,24 @@ describe('readWorkspaceFileText', () => { expect(mocks.fetchServable.mock.calls[0][2]).toMatchObject({ maxBytes: expect.any(Number) }) }) + + /** + * The artifact branch renders the same caller-supplied ceiling, so it needs + * the same exact-byte formatting the source branch does. + */ + it('names a sub-1 KB artifact limit in bytes', async () => { + mocks.getFile.mockResolvedValueOnce( + fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 10 }) + ) + mocks.fetchServable.mockRejectedValueOnce( + new PayloadSizeLimitError({ label: 'artifact', maxBytes: 27 }) + ) + + await expect( + readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 27 }) }) + ).rejects.toMatchObject({ + code: 'payload_too_large', + message: expect.stringContaining('renders to more than 27 Bytes'), + }) + }) }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts index 2f3600159b2..ad924af8528 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts @@ -50,9 +50,14 @@ export interface ReadWorkspaceFileTextResult { */ async function readSourceBuffer(file: WorkspaceFileRecord, maxBytes: number): Promise { if (file.size > maxBytes) { + /** + * Sizes render with `includeBytes` because a caller-supplied `maxBytes` is + * routinely under 1 KB, and the default formatting collapses every sub-1 KB + * value to "0 Bytes" — naming neither the real size nor the limit to raise. + */ throw new OrchestrationError( 'payload_too_large', - `"${file.name}" is ${formatFileSize(file.size)}, above the ${formatFileSize(maxBytes)} text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}` + `"${file.name}" is ${formatFileSize(file.size, { includeBytes: true })}, above the ${formatFileSize(maxBytes, { includeBytes: true })} text-extraction limit; download the raw bytes instead of extracting text` ) } return fetchWorkspaceFileBuffer(file, { maxBytes }) @@ -74,7 +79,7 @@ async function executeReadWorkspaceFileText({ if (!isSupportedFileType(extension)) { throw new OrchestrationError( 'validation', - `Text extraction is not supported for "${file.name}"; download the raw bytes with GET /api/v2/files/${file.id}` + `Text extraction is not supported for "${file.name}"; download the raw bytes instead of extracting text` ) } @@ -93,7 +98,7 @@ async function executeReadWorkspaceFileText({ await resolveRenderedWorkspaceArtifact(file, principal, { maxBytes, tooLargeMessage: (limit) => - `"${file.name}" renders to more than ${limit}, above the text-extraction limit; download the raw bytes with GET /api/v2/files/${file.id}`, + `"${file.name}" renders to more than ${limit}, above the text-extraction limit; download the raw bytes instead of extracting text`, }) ).buffer : await readSourceBuffer(file, maxBytes) diff --git a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts index 0cee7dbd69c..3b6cc4e29e9 100644 --- a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts +++ b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts @@ -46,7 +46,7 @@ export async function resolveRenderedWorkspaceArtifact( ) } if (isPayloadSizeLimitError(error)) { - const limit = formatFileSize(options.maxBytes) + const limit = formatFileSize(options.maxBytes, { includeBytes: true }) throw new OrchestrationError( 'payload_too_large', options.tooLargeMessage?.(limit) ?? From 452dcd1a01d08ea8ba3ed4882a9cfcb933f98d89 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:12:30 -0700 Subject: [PATCH 07/15] fix(cli): stop a config value forging a section it was never meant to write The config file is written by joining names and values into INI lines, and nothing checked what was in them. A profile name carrying a newline and a section header wrote a section that merged into a different profile and took over its endpoint - and the next command sent that profile's stored API key there. A workspace value could do the same from the other side, since only the endpoint flag validated its input. The refusal now lives at the writer, the single place untrusted text enters the document, with the flag-level checks kept for the better message. Either alone blocks the forgery; the pair is deliberate. Rejecting rather than escaping, because the format has no escape syntax and these files are hand-edited and read by other tools that would not decode one we invented. The forbidden set covers control characters and the two Unicode line separators, which the previous guard missed - those parse as an unreadable line, so the key silently vanished on read and the next write appended a duplicate while the command reported success. Login also wrote the key before the settings, so a malformed response from the deployment could leave a key on disk with no endpoint beside it, and the next command would send it to the default host. Settings are written first, and the response is checked before anything touches disk. Name validation applies only when creating a profile, so a hand-written one that predates the rule keeps working. --- packages/sim-cli/src/commands/auth.test.ts | 127 +++++++++++++++++- packages/sim-cli/src/commands/auth.ts | 117 +++++++++++----- .../sim-cli/src/commands/configure.test.ts | 94 ++++++++++++- packages/sim-cli/src/commands/configure.ts | 43 +++++- packages/sim-cli/src/config/index.ts | 4 + packages/sim-cli/src/config/ini.test.ts | 81 +++++++++++ packages/sim-cli/src/config/ini.ts | 83 +++++++++++- packages/sim-cli/src/config/profile.test.ts | 91 ++++++++++++- packages/sim-cli/src/config/profile.ts | 60 ++++++++- 9 files changed, 651 insertions(+), 49 deletions(-) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 0f5f6bb5e96..7a824aeaabe 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -41,7 +41,17 @@ vi.mock('../auth/device-flow', () => ({ createAuthRequest: mocks.createAuthRequest, pollForKey: mocks.pollForKey, })) -vi.mock('../config/index', () => ({ +/** + * The two validators come from the real module rather than a copy: a duplicated + * pattern here would keep passing if the shipped one were deleted, which is + * exactly the regression these tests exist to catch. `../config/profile` is not + * itself mocked, so this is the shipped implementation. + */ +vi.mock('../config/index', async () => ({ + ...(await import('../config/profile').then(({ normalizeWorkspaceId, validateProfileName }) => ({ + normalizeWorkspaceId, + validateProfileName, + }))), configPath: () => '/tmp/sim-config', credentialsPath: () => '/tmp/sim-credentials', DEFAULT_PROFILE: 'default', @@ -232,6 +242,53 @@ describe('login command', () => { expect(mocks.createAuthRequest).toHaveBeenCalledOnce() }) + it('writes the endpoint before the key, so a failed write cannot strand one', async () => { + // A key on disk with no endpoint beside it falls back to the default host + // on the next command, which would send a self-hosted key elsewhere. + setInteractive(false) + const order: string[] = [] + mocks.writeConfigProfile.mockImplementation(() => { + order.push('config') + }) + mocks.writeCredentialsProfile.mockImplementation(() => { + order.push('credentials') + }) + + await login() + + expect(order).toEqual(['config', 'credentials']) + }) + + it('stores nothing when the server answers with an unstorable workspace id', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1\nendpoint = http://elsewhere.invalid', + }) + + await expect(login()).rejects.toThrow('Invalid workspace id') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + + it('stores nothing when the server answers with a malformed key', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey: ' ', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + + await expect(login()).rejects.toThrow('malformed API key') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + it('clears a stale workspace default when none is selected during login', async () => { setInteractive(false) mocks.profileFrom.mockReturnValue({ @@ -449,6 +506,13 @@ describe('profiles command', () => { expect(mocks.writeConfigProfile).not.toHaveBeenCalled() }) + it('refuses a new profile name that would forge a config section', async () => { + await expect(profiles('add', 'evil]\n[default', '--workspace', 'ws_acme')).rejects.toThrow( + 'Invalid profile name' + ) + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + it('does not overwrite an existing profile', async () => { mocks.listProfiles.mockReturnValue(['acme']) @@ -467,8 +531,65 @@ describe('profiles command', () => { await profiles('list') const output = vi.mocked(console.log).mock.calls.flat().join('\n') - expect(output).toContain('acme (auth: default)') - expect(output).not.toContain('acme (no key)') + expect(output).toMatch(/acme\s+yes\s+default/) + }) + + it('refuses an unknown profile like every other command', async () => { + // `profiles list --profile typo` used to exit 0 on a name that resolves to + // nothing, alone among the commands, because it never resolved at all. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown profile "bogus".') + }) + + await expect(profiles('list', '--profile', 'bogus')).rejects.toThrow('Unknown profile "bogus".') + expect(console.log).not.toHaveBeenCalled() + }) + + it('renders the listing in the resolved output format', async () => { + mocks.listProfiles.mockReturnValue(['acme', 'default']) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: 'stored-key', + workspaceId: 'ws_default', + output: 'json', + sources: { + endpoint: 'config', + apiKey: 'credentials', + workspaceId: 'config', + output: 'config', + }, + }) + + await profiles('list') + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(JSON.parse(output)).toEqual([ + { name: 'acme', active: false, hasKey: true, authProfile: 'acme', error: null }, + { name: 'default', active: true, hasKey: true, authProfile: 'default', error: null }, + ]) + }) + + it('answers a machine format with an empty list rather than prose', async () => { + mocks.listProfiles.mockReturnValue([]) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: null, + output: 'json', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'unset', + output: 'config', + }, + }) + + await profiles('list') + + expect(JSON.parse(vi.mocked(console.log).mock.calls.flat().join('\n'))).toEqual([]) }) it('marks a broken profile and still lists the rest', async () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index e2ebab1ed18..2f1cd0135fe 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -15,11 +15,13 @@ import { deleteProfile, listAuthenticationDependents, listProfiles, + normalizeWorkspaceId, ProfileConfigError, type ResolvedProfile, readCredentialsProfile, resolveAuthenticationProfileName, type SettingSource, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from '../config/index' @@ -31,11 +33,10 @@ import { V2_OPERATIONS, } from '../generated/v2-api' import { requestAllPages, resolvePath, SimApiError, type SimClient } from '../http/client' -import { printRecord, safeOneLine } from '../output/render' +import { type Column, printList, printRecord, safeOneLine, text } from '../output/render' type SelectableWorkspace = ListWorkspacesResponse['data'][number] -const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ const MAX_INTERACTIVE_WORKSPACES = 1000 /** @@ -108,15 +109,29 @@ function selectedProfileName(command: Command): string { } function validateNewProfileName(profileName: string): void { - if (!PROFILE_NAME_PATTERN.test(profileName)) { + // The shape rule lives with the config writer, so `profiles add`, `login + // --profile`, and `configure --profile` cannot drift into three answers. + validateProfileName(profileName) + if (listProfiles().includes(profileName)) { throw new SimApiError( - `Invalid profile name "${profileName}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.`, + `Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`, 0 ) } - if (listProfiles().includes(profileName)) { +} + +/** + * Refuses a minted key the credentials file could not represent. + * + * The poll response is remote input, and the deployment answering it is + * whatever the endpoint names. A key carrying a line break would be written + * verbatim into an escape-less format, so the writer refuses it — this refuses + * it one step earlier, before anything is on disk, and says which side is wrong. + */ +function requireStorableKey(apiKey: unknown): void { + if (typeof apiKey !== 'string' || !apiKey.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(apiKey)) { throw new SimApiError( - `Profile "${profileName}" already exists. Remove it first with: sim logout --all --profile ${profileName}`, + 'The server returned a malformed API key. Nothing was stored; check the endpoint.', 0 ) } @@ -290,17 +305,25 @@ export function loginCommand(): Command { ) } - writeCredentialsProfile(profile.name, key.apiKey) - // The workspace picked in the browser becomes the profile's default, // whether or not the key is scoped to it. The user chose it by name — // making them look up its id afterwards would waste the one moment the - // answer was already on screen. + // answer was already on screen. It arrives off the wire, so it is + // checked before either file is touched. const settings: Record = { endpoint: profile.endpoint, - workspace: key.workspaceId ?? null, + workspace: key.workspaceId + ? normalizeWorkspaceId(key.workspaceId, 'the login response') + : null, } + requireStorableKey(key.apiKey) + + // Config before credentials: the endpoint decides where the key is sent + // later. Storing the key first and then failing on the settings left a + // key on disk with no endpoint beside it, so the next command fell back + // to the default host — sending a self-hosted key somewhere else. writeConfigProfile(profile.name, settings) + writeCredentialsProfile(profile.name, key.apiKey) console.log(chalk.green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) if (key.workspaceBound && key.workspaceId) { @@ -597,38 +620,66 @@ export function whoamiCommand(): Command { }) } +interface ProfileRow { + name: string + active: boolean + hasKey: boolean + /** The profile whose stored login this one uses; itself unless it is an alias. */ + authProfile: string | null + /** Why the row could not be resolved, for a profile with a broken `auth_profile`. */ + error: string | null +} + +const PROFILE_COLUMNS: Column[] = [ + { header: '', value: (row) => (row.active ? chalk.green('*') : ' ') }, + { header: 'profile', value: (row) => text(row.name) }, + { header: 'key', value: (row) => (row.error ? text(null) : row.hasKey ? 'yes' : 'no') }, + { header: 'auth', value: (row) => text(row.authProfile) }, + { header: 'error', value: (row) => (row.error ? chalk.red(safeOneLine(row.error)) : text(null)) }, +] + +/** + * `profiles` is the command someone runs *because* a profile is broken, so a bad + * `auth_profile` marks its own row rather than aborting the listing and leaving + * them with nothing shown at all. + */ +function buildProfileRow(name: string, active: boolean): ProfileRow { + try { + const authProfile = resolveAuthenticationProfileName(name) + return { + name, + active, + hasKey: Boolean(readCredentialsProfile(authProfile).api_key), + authProfile, + error: null, + } + } catch (error) { + if (!(error instanceof ProfileConfigError)) throw error + return { name, active, hasKey: false, authProfile: null, error: error.message } + } +} + export function profilesCommand(): Command { const command = new Command('profiles') .alias('profile') .description('List profiles or add a workspace profile that shares a stored login') const printProfiles = (_options: unknown, actionCommand: Command): void => { - const profiles = listProfiles() - if (profiles.length === 0) { - console.log(chalk.dim('No profiles yet. Run: sim login')) + // Resolving is what makes `profiles --profile typo` fail like every other + // command instead of listing happily under a name that resolves to nothing, + // and it is also what supplies the output format the listing renders in. + const profile = profileFrom(actionCommand) + const rows = listProfiles().map((name) => buildProfileRow(name, name === profile.name)) + + if (rows.length === 0) { + // The prose belongs to the human formats; a script asking for json must + // get an empty list, not a sentence it cannot parse. + if (profile.output === 'table') console.log(chalk.dim('No profiles yet. Run: sim login')) + else printList(profile.output, rows, PROFILE_COLUMNS) return } - const active = selectedProfileName(actionCommand) - for (const name of profiles) { - const marker = name === active ? chalk.green('*') : ' ' - - // `profiles` is the command someone runs *because* a profile is broken, - // so one bad `auth_profile` must mark its own row rather than abort the - // listing and leave them with no profiles shown at all. - let authProfile: string - try { - authProfile = resolveAuthenticationProfileName(name) - } catch (error) { - if (!(error instanceof ProfileConfigError)) throw error - console.log(`${marker} ${name}${chalk.red(` (${safeOneLine(error.message)})`)}`) - continue - } - - const hasKey = Boolean(readCredentialsProfile(authProfile).api_key) - const authentication = authProfile === name ? '' : chalk.dim(` (auth: ${authProfile})`) - console.log(`${marker} ${name}${hasKey ? '' : chalk.dim(' (no key)')}${authentication}`) - } + printList(profile.output, rows, PROFILE_COLUMNS) } command.action(printProfiles) diff --git a/packages/sim-cli/src/commands/configure.test.ts b/packages/sim-cli/src/commands/configure.test.ts index 521f5279853..094b535a4bb 100644 --- a/packages/sim-cli/src/commands/configure.test.ts +++ b/packages/sim-cli/src/commands/configure.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { readConfigProfile, writeConfigProfile, writeCredentialsProfile } from '../config/index' +import { + listProfiles, + readConfigProfile, + writeConfigProfile, + writeCredentialsProfile, +} from '../config/index' import { configureCommand } from './configure' const mocks = vi.hoisted(() => ({ @@ -12,13 +17,24 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('../context', () => ({ + // The real one-liner: the root globals live on the root command, so the + // refusal below only fires if the harness parses argv the way the shipped + // program does. + globalsOf: (command: Command) => command.optsWithGlobals(), profileFrom: mocks.profileFrom, })) let dir: string function run(...args: string[]): Promise { - const root = new Command('sim').exitOverride() + // The three root globals are declared exactly as program.ts declares them, so + // `configure --endpoint …` parses here the way it does in the shipped tree. + const root = new Command('sim') + .exitOverride() + .option('-P, --profile ') + .option('--endpoint ') + .option('-w, --workspace ') + .option('--output ') root.addCommand(configureCommand()) return root.parseAsync(['node', 'sim', 'configure', ...args]) } @@ -108,3 +124,77 @@ describe('configure --set-endpoint', () => { ) }) }) + +describe('configure --set-workspace', () => { + /** + * A stored value is read back as a real setting, so a value carrying a line + * break used to add a setting nobody typed — `endpoint` included, which is + * what decides where the API key is sent. Its sibling `--set-endpoint` has + * been validated all along; this is the same check for the other value. + */ + it('refuses a workspace value that would inject another setting', async () => { + await expect( + run('--set-workspace', 'ws_1\nendpoint = http://elsewhere.invalid') + ).rejects.toThrow(/Invalid workspace id/) + + expect(readConfigProfile('default')).toEqual({}) + }) + + it('stores an ordinary workspace id, trimmed', async () => { + await run('--set-workspace', ' ws_new ') + expect(readConfigProfile('default')).toEqual({ workspace: 'ws_new' }) + }) + + /** + * `listProfiles` counts section names, so the empty section an unset used to + * leave behind made the typo guard accept that name from then on. + */ + it('creates nothing when unsetting on a profile that does not exist', async () => { + mocks.profileName = 'fresh' + + await run('--unset', 'workspace') + + expect(readConfigProfile('fresh')).toEqual({}) + expect(listProfiles()).not.toContain('fresh') + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('No settings stored')) + }) +}) + +/** + * The root globals are transient overrides on every other command, so + * `configure --endpoint …` discarded the value and exited 0 after printing the + * settings it had not changed — which reads like a confirmation. + */ +describe('configure and the root globals', () => { + it('refuses --endpoint and names the flag that stores it', async () => { + await expect(run('--endpoint', 'https://other.example')).rejects.toThrow( + 'sim configure --set-endpoint https://other.example' + ) + expect(readConfigProfile('default')).toEqual({}) + }) + + it('refuses -w and --output the same way', async () => { + await expect(run('-w', 'ws_9')).rejects.toThrow('sim configure --set-workspace ws_9') + await expect(run('--output', 'json')).rejects.toThrow('sim configure --set-output json') + expect(readConfigProfile('default')).toEqual({}) + }) + + it('does not print a stale stored value as if it had been set', async () => { + writeConfigProfile('default', { endpoint: 'https://staging.example' }) + + await expect(run('--endpoint', 'https://other.example')).rejects.toThrow('--set-endpoint') + + expect(console.log).not.toHaveBeenCalled() + expect(readConfigProfile('default')).toEqual({ endpoint: 'https://staging.example' }) + }) + + it('still prints stored settings and still stores a --set- flag', async () => { + writeConfigProfile('default', { endpoint: 'https://staging.example' }) + + await run() + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('https://staging.example')) + + await run('--set-endpoint', 'https://x.example') + expect(readConfigProfile('default')).toMatchObject({ endpoint: 'https://x.example' }) + }) +}) diff --git a/packages/sim-cli/src/commands/configure.ts b/packages/sim-cli/src/commands/configure.ts index 3b3088fa4c7..3caa4d46985 100644 --- a/packages/sim-cli/src/commands/configure.ts +++ b/packages/sim-cli/src/commands/configure.ts @@ -7,10 +7,26 @@ import { resolveAuthenticationProfileName, writeConfigProfile, } from '../config/index' -import { normalizeEndpoint } from '../config/profile' -import { profileFrom } from '../context' +import { normalizeEndpoint, normalizeWorkspaceId } from '../config/profile' +import { globalsOf, profileFrom } from '../context' import { SimApiError } from '../http/client' +/** + * The root globals that have a `--set-` twin on this command. + * + * `sim configure --endpoint https://x` parses cleanly — the globals are legal + * anywhere in argv — but `configure` only ever stored its own `--set-` flags, so + * the value was discarded and the command exited 0 after printing the settings + * it did not change, which reads exactly like a confirmation. Refusing and + * naming the twin is the honest answer; making the global write instead would + * give one command a persistent side effect the same flag has on no other. + */ +const GLOBAL_FLAG_TWINS = [ + { option: 'endpoint', flag: '--endpoint', setFlag: '--set-endpoint' }, + { option: 'workspace', flag: '-w, --workspace', setFlag: '--set-workspace' }, + { option: 'output', flag: '--output', setFlag: '--set-output' }, +] as const + /** * Rejects a `--set-…` flag given an empty value. * @@ -49,6 +65,16 @@ export function configureCommand(): Command { }, command: Command ) => { + const globals = globalsOf(command) + for (const { option, flag, setFlag } of GLOBAL_FLAG_TWINS) { + const value = globals[option] + if (value === undefined) continue + throw new SimApiError( + `${flag} applies to a single command and is not stored. To save it, run: sim configure ${setFlag} ${value}`, + 0 + ) + } + // `configure --profile x --set-…` is a documented way to create a // profile, so the name is allowed to be one that does not exist yet. const profile = profileFrom(command, { allowUnknownProfile: true }) @@ -68,7 +94,9 @@ export function configureCommand(): Command { } updates.endpoint = normalizeEndpoint(options.setEndpoint, '--set-endpoint') } - if (options.setWorkspace) updates.workspace = options.setWorkspace + if (options.setWorkspace) { + updates.workspace = normalizeWorkspaceId(options.setWorkspace, '--set-workspace') + } if (options.setOutput) { if (!(OUTPUT_FORMATS as readonly string[]).includes(options.setOutput)) { throw new SimApiError( @@ -104,6 +132,15 @@ export function configureCommand(): Command { return } + // An unset against a profile with nothing stored writes nothing — the + // file layer refuses to conjure a section for a removal — so reporting + // an update would claim a change that did not happen. + const removalOnly = Object.values(updates).every((value) => value === null) + if (removalOnly && Object.keys(readConfigProfile(profile.name)).length === 0) { + console.log(chalk.dim(`No settings stored for profile "${profile.name}".`)) + return + } + writeConfigProfile(profile.name, updates) console.log(chalk.green(`✓ Updated profile "${profile.name}" in ${configPath()}`)) } diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 699cc718f04..1d6a3da6d86 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -5,8 +5,11 @@ export { deleteProfile, listAuthenticationDependents, listProfiles, + normalizeEndpoint, + normalizeWorkspaceId, OUTPUT_FORMATS, type OutputFormat, + PROFILE_NAME_PATTERN, ProfileConfigError, type ProfileOverrides, type ResolvedProfile, @@ -15,6 +18,7 @@ export { resolveAuthenticationProfileName, resolveProfile, type SettingSource, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from './profile' diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts index 12e0afe8f6c..b9f7441af61 100644 --- a/packages/sim-cli/src/config/ini.test.ts +++ b/packages/sim-cli/src/config/ini.test.ts @@ -174,3 +174,84 @@ output = json ) }) }) + +/** + * The format has no escape syntax, so anything that can end a line is structure + * rather than data. These pin the refusal at the writer — the single place + * untrusted text enters the document. + */ +describe('ini write guards', () => { + const INJECTIONS = [ + 'ws_1\nendpoint = http://elsewhere.invalid', + 'ws_1\r\nendpoint = http://elsewhere.invalid', + 'ws_1\u2028endpoint = http://elsewhere.invalid', + 'ws_1\u2029endpoint = http://elsewhere.invalid', + ] + + it('refuses a value that would be read back as a second setting', () => { + for (const value of INJECTIONS) { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: value })).toThrow( + /Refusing to write a value/ + ) + } + }) + + it('refuses a section name that would forge another section header', () => { + const doc = parseIni(SAMPLE) + expect(() => + setSectionValues(doc, 'profile evil]\n[default', { workspace: 'ws_evil' }) + ).toThrow(/Refusing to write a section/) + expect(() => setSectionValues(doc, 'profile evil]', { workspace: 'ws_evil' })).toThrow( + /Refusing to write a section/ + ) + }) + + /** + * The assertion that matters: whatever is written, reading the file back + * cannot produce a section or a setting nobody asked for. + */ + it('cannot forge a section or a setting through a write-then-read cycle', () => { + for (const payload of [...INJECTIONS, 'ws]\n[default]\nendpoint = http://elsewhere.invalid']) { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, `profile ${payload}`, { workspace: 'ws' })).toThrow() + expect(() => setSectionValues(doc, 'profile dev', { workspace: payload })).toThrow() + + const reread = parseIni(serializeIni(doc)) + expect(listSections(reread)).toEqual(['default', 'profile dev']) + expect(getSection(reread, 'default')).toEqual({ + endpoint: 'https://sim.ai', + workspace: 'ws_1', + }) + expect(getSection(reread, 'profile dev')).toEqual({ endpoint: 'http://localhost:3000' }) + } + }) + + /** + * A whitespace-only value reads back as the empty string, so the key would + * look stored while resolving as unset. + */ + it('refuses a blank value', () => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: ' ' })).toThrow(/blank value/) + }) + + it('leaves a legitimate value untouched', () => { + const doc = parseIni(SAMPLE) + setSectionValues(doc, 'profile staging-1.eu', { endpoint: 'https://staging.example' }) + expect(getSection(parseIni(serializeIni(doc)), 'profile staging-1.eu')).toEqual({ + endpoint: 'https://staging.example', + }) + }) + + /** + * `listProfiles` counts section names, so a section conjured by a removal made + * an unknown profile pass the "does this profile exist?" check for good. + */ + it('does not create a section for a removal-only update', () => { + const doc = parseIni('') + setSectionValues(doc, 'profile fresh', { workspace: null }) + expect(listSections(doc)).toEqual([]) + expect(serializeIni(doc)).toBe('') + }) +}) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts index 412e691c2f0..a71f047c38b 100644 --- a/packages/sim-cli/src/config/ini.ts +++ b/packages/sim-cli/src/config/ini.ts @@ -12,6 +12,21 @@ * handful of flat string settings. */ +/** + * An invalid stored setting, or a value the config files cannot represent. + * + * Defined here rather than in `profile.ts` because the writer below is the + * lowest layer that rejects input, and `profile.ts` already imports this module. + * `profile.ts` re-exports it, so callers keep seeing one error type — the one + * the entrypoint renders as a message instead of a stack trace. + */ +export class ProfileConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'ProfileConfigError' + } +} + type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string } interface Section { @@ -28,6 +43,48 @@ export interface IniDocument { const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ +/** + * Characters a stored value may not contain. + * + * The format has no escape syntax (see the module note), so text that can end a + * line is structure, not data: a value carrying a line break was written + * verbatim and read back on the next load as a *second* setting in the same + * section. That is the class of bug this closes — untrusted text reaching the + * serializer could add settings nobody typed. + * + * The set is every C0 and C1 control character plus U+2028 and U+2029, the two + * Unicode line separators. The separators matter for a second reason: they are + * not line breaks to the reader below, so {@link KV_PATTERN} (whose `.` never + * matches them) fails and the line is kept as opaque `raw` text — the key + * silently vanishes on the next read although the write reported success, and + * because the dead line no longer matches the key, the write after that appends + * a duplicate. + */ +const FORBIDDEN_IN_VALUE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ + +/** As {@link FORBIDDEN_IN_VALUE}, plus the brackets that would close or open a header. */ +const FORBIDDEN_IN_NAME = /[\u0000-\u001f\u007f-\u009f\u2028\u2029[\]]/ + +/** Keys have to round-trip through the reader's own key pattern. */ +const WRITABLE_KEY = /^[A-Za-z0-9_.-]+$/ + +/** + * Refuses text that would not survive a write-then-read cycle as the same value. + * + * Rejecting rather than escaping is deliberate: the format has no escape + * syntax, these files are hand-edited and read by AWS-style tooling that would + * not decode one, and no legitimate profile name, workspace id, endpoint, or + * API key contains a line break or a bracket. Inventing an escape here would + * make the files unreadable to everything else that opens them. + */ +function assertWritable(text: string, what: string, forbidden: RegExp): void { + if (forbidden.test(text)) { + throw new ProfileConfigError( + `Refusing to write ${what}: line breaks and control characters cannot be stored in the ~/.sim files, because the format has no way to escape them.` + ) + } +} + export function parseIni(text: string): IniDocument { const doc: IniDocument = { preamble: [], sections: [] } let current: Section | null = null @@ -116,16 +173,40 @@ export function listSections(doc: IniDocument): string[] { * {@link getSection}. A removal instead has to clear every block and every * repeat of the key within one: deleting only the first left a later duplicate * to win the merged read, so `--unset` reported success while the value stayed - * in force. + * in force. A removal against a section that is not there writes nothing at all. + * + * This is the one place untrusted text enters the document, so it is where the + * write is refused: see {@link FORBIDDEN_IN_VALUE} for what cannot be stored + * and why the answer is a refusal rather than an escape. */ export function setSectionValues( doc: IniDocument, name: string, values: Record ): void { + // Everything is checked before anything is written, so a rejected write + // leaves the document exactly as it was rather than half-applied. + assertWritable(name, `a section named "${name}"`, FORBIDDEN_IN_NAME) + for (const [key, value] of Object.entries(values)) { + if (!WRITABLE_KEY.test(key)) { + throw new ProfileConfigError(`Refusing to write an unreadable setting name "${key}".`) + } + if (value === null) continue + assertWritable(value, `a value for "${key}"`, FORBIDDEN_IN_VALUE) + // A value that is only whitespace reads back as the empty string, so the + // key would look stored and resolve as unset. + if (value.trim() === '') { + throw new ProfileConfigError(`Refusing to write a blank value for "${key}".`) + } + } + const matching = doc.sections.filter((s) => s.name === name) let section = matching[0] if (!section) { + // A removal has nothing to create the section for, and an empty section is + // not inert: `listProfiles` counts section names, so conjuring one made an + // unknown profile permanently pass the "does this profile exist?" check. + if (Object.values(values).every((value) => value === null)) return section = { name, entries: [] } doc.sections.push(section) matching.push(section) diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index 5d422447723..b6e7e8407ab 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -11,6 +11,7 @@ import { OUTPUT_FORMATS, resolveAuthenticationProfileName, resolveProfile, + validateProfileName, writeConfigProfile, writeCredentialsProfile, } from './profile' @@ -115,7 +116,9 @@ describe('profile resolution', () => { }) it('fails fast on empty, missing, self-referential, or chained auth profiles', () => { - writeConfigProfile('empty', { auth_profile: '' }) + // Written by hand, because the writer refuses a blank value: it reads back + // as unset while the write reports success. + writeFileSync(configPath(), '[profile empty]\nauth_profile =\n') expect(() => resolveProfile({ profile: 'empty' })).toThrow( 'Profile "empty" has an empty auth_profile.' ) @@ -348,3 +351,87 @@ describe('profile resolution', () => { }) }) }) + +/** + * Config values are serialized without escaping — the format has no escape + * syntax — so text carrying a line break used to be read back as structure: an + * extra setting, or a header for a different profile. Since `endpoint` is what + * decides where the API key is sent, that made a stored name or value a way to + * redirect the key. + */ +describe('config file injection', () => { + const FORGED_SECTION = 'evil]\n[default]\nendpoint = http://elsewhere.invalid\n[x' + const FORGED_SETTING = 'ws_1\nendpoint = http://elsewhere.invalid' + + it('refuses to create a profile whose name would forge a section', () => { + expect(() => resolveProfile({ profile: FORGED_SECTION, allowUnknownProfile: true })).toThrow( + /Invalid profile name/ + ) + }) + + it('still resolves an existing profile whose name predates the rule', () => { + // The shape rule governs creation only: a hand-written section keeps + // working, whatever it is called. + writeFileSync(configPath(), '[profile my stack]\nworkspace = ws_hand\n') + + expect(resolveProfile({ profile: 'my stack' })).toMatchObject({ workspaceId: 'ws_hand' }) + expect(resolveProfile({ profile: 'my stack', allowUnknownProfile: true })).toMatchObject({ + workspaceId: 'ws_hand', + }) + expect(() => validateProfileName('my stack')).toThrow(/Invalid profile name/) + }) + + it('refuses to write a profile name that would forge a section', () => { + expect(() => writeConfigProfile(FORGED_SECTION, { workspace: 'ws_evil' })).toThrow( + /Refusing to write a section/ + ) + + expect(existsSync(configPath())).toBe(false) + expect(resolveProfile().endpoint).toBe(DEFAULT_ENDPOINT) + }) + + it('refuses to write a value that would forge a setting', () => { + writeConfigProfile('default', { workspace: 'ws_ok' }) + + expect(() => writeConfigProfile('default', { workspace: FORGED_SETTING })).toThrow( + /Refusing to write a value/ + ) + + expect(readFileSync(configPath(), 'utf8')).not.toContain('elsewhere.invalid') + expect(resolveProfile()).toMatchObject({ + endpoint: DEFAULT_ENDPOINT, + workspaceId: 'ws_ok', + }) + }) + + it('refuses the same through the credentials file', () => { + // The credentials reader merges duplicate sections too, so a forged + // `[victim]` block there would be read as a real key. + expect(() => writeCredentialsProfile(FORGED_SECTION, 'key_evil')).toThrow( + /Refusing to write a section/ + ) + expect(() => writeCredentialsProfile('default', 'key\napi_key = other')).toThrow( + /Refusing to write a value/ + ) + expect(existsSync(credentialsPath())).toBe(false) + }) + + it('leaves an ordinary profile name and value writable', () => { + writeConfigProfile('staging-1.eu', { endpoint: 'https://staging.example' }) + writeCredentialsProfile('staging-1.eu', 'sim_key') + + expect(resolveProfile({ profile: 'staging-1.eu' })).toMatchObject({ + endpoint: 'https://staging.example', + apiKey: 'sim_key', + }) + }) + + it('does not conjure a profile out of an unset', () => { + // An empty section is not inert: `listProfiles` counts section names, so it + // made the unknown-profile guard accept the name from then on. + writeConfigProfile('phantom', { workspace: null }) + + expect(listProfiles()).not.toContain('phantom') + expect(() => resolveProfile({ profile: 'phantom' })).toThrow(/Unknown profile/) + }) +}) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 7e8025a5f4c..7359e1b91e8 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -4,6 +4,7 @@ import { getSection, type IniDocument, listSections, + ProfileConfigError, parseIni, removeSection, serializeIni, @@ -35,11 +36,31 @@ export const DEFAULT_ENDPOINT = 'https://www.sim.ai' export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] -/** An invalid active profile setting that the user can correct. */ -export class ProfileConfigError extends Error { - constructor(message: string) { - super(message) - this.name = 'ProfileConfigError' +export { ProfileConfigError } from './ini' + +/** + * The shape a newly created profile name has to have. + * + * Enforced only when a profile is being created. A name reaches the config file + * as part of a section header, and the file format has no escape syntax, so a + * name that carries a bracket or a line break would forge a header for another + * profile — the writer refuses that outright, and this pattern is the friendlier + * refusal that names the rule instead of the mechanism. + */ +export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + +/** + * Refuses a name for a profile that does not exist yet. + * + * Creation only, deliberately: a hand-written `[profile my stack]` predates this + * rule and must keep resolving, so the read path stays governed by + * {@link requireKnownProfile} alone. + */ +export function validateProfileName(name: string): void { + if (!PROFILE_NAME_PATTERN.test(name)) { + throw new ProfileConfigError( + `Invalid profile name "${name}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.` + ) } } @@ -301,6 +322,29 @@ export function normalizeEndpoint(endpoint: string, source: string): string { return trimmed } +/** + * Validates a workspace id on its way into the config file. + * + * The sibling of {@link normalizeEndpoint}, and for the same reason: a stored + * setting is read back as a real setting, so a value that could carry a line + * break would come back as an extra setting the user never typed — including an + * `endpoint`, which decides where the API key is sent. Only structure is + * checked, not the id's shape: ids are server-minted and the CLI has no business + * deciding what one may look like. + */ +export function normalizeWorkspaceId(workspaceId: string, source: string): string { + const trimmed = workspaceId.trim() + if (!trimmed) { + throw new ProfileConfigError(`Empty workspace id from ${source}.`) + } + if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(trimmed)) { + throw new ProfileConfigError( + `Invalid workspace id "${trimmed.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')}" from ${source}. A workspace id cannot contain line breaks or control characters.` + ) + } + return trimmed +} + /** * Resolves one setting through the precedence chain, reporting where it landed. * Order is flags → environment → files → built-in default, the same order every @@ -322,6 +366,12 @@ export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfil const named = overrides.profile || process.env.SIM_PROFILE const name = named || DEFAULT_PROFILE if (named && !overrides.allowUnknownProfile) requireKnownProfile(named) + // `allowUnknownProfile` means "this profile is about to be created", which is + // the only moment the name shape is the CLI's to decide. An existing profile, + // however it was written, keeps resolving. + if (named && overrides.allowUnknownProfile && !listProfiles().includes(named)) { + validateProfileName(named) + } const config = readConfigProfile(name) const authProfile = resolveAuthenticationProfileName(name) From bd35b81c4335f4fa9966ee85a32b620f6db09182 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:17:44 -0700 Subject: [PATCH 08/15] fix(docs): tell the reader which key a command needs, and stop the ids contradicting the CLI Around sixty v2 operations refuse a workspace API key, and the CLI's help said nothing about it - the caller found out from a 403 after the request went out. The restriction is already stated in the API spec, so the generator now reads it from there and the command description carries it. The sentinel sentences are imported from the spec's own constants rather than copied, so a reword cannot silently unmark every command, and the test pins the count as well as named operations because a reword confined to one family would otherwise slip past. The generated reference also rendered an empty default as a sentence pointing at nothing - "Defaults to ." - for every repeatable filter. Omitted now, while false and zero still render, which is the trap that shape of check usually walks into. The hand-written guides used a workflow-shaped id for workflows that the CLI's own help says never names one, and five other families were equally wrong. All of them now match the scheme the CLI declares, consistently per entity across pages, with the shared ones taken from that help text so the two read as one voice. The page documenting every flag was linked from nowhere; both landing links pointed at the overview instead. And the generator's test file was absent from the hand-maintained list CI runs, so its guards never executed. --- .../content/docs/en/cli/authentication.mdx | 16 +- .../content/docs/en/cli/configuration.mdx | 18 +-- apps/docs/content/docs/en/cli/index.mdx | 21 +-- apps/docs/content/docs/en/cli/output.mdx | 8 +- apps/docs/content/docs/en/cli/scripting.mdx | 26 +-- .../content/docs/en/cli/troubleshooting.mdx | 10 +- package.json | 2 +- scripts/generate-cli-docs.test.ts | 152 ++++++++++++++++++ scripts/generate-cli-docs.ts | 30 +++- scripts/generate-v2-cli-api.test.ts | 64 +++++++- scripts/generate-v2-cli-api.ts | 74 +++++++-- 11 files changed, 353 insertions(+), 68 deletions(-) create mode 100644 scripts/generate-cli-docs.test.ts diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 40cdf96e307..68461afd01f 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -24,7 +24,7 @@ https://www.sim.ai/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials - Personal key, defaulting to ws_abc123. Override per command with --workspace. + Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` There is no loopback listener, so this works over SSH and inside containers. @@ -47,7 +47,7 @@ profile's default `workspace`; it does **not** restrict the key to that workspace. Target another workspace the key can reach with `--workspace`: ```bash -sim workflows list --workspace ws_other +sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` `sim login --workspace ` preselects a workspace in the picker, and @@ -58,7 +58,7 @@ a workspace profile: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme whoami ``` @@ -108,9 +108,9 @@ config file: ```bash export SIM_API_KEY="sim_…" -export SIM_WORKSPACE="ws_abc123" +export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67" -sim workflows run wf_7Yb2 --input '{"source":"nightly"}' --output json +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json ``` Create the key in Sim under **Settings → API keys**. Store it as a secret in your @@ -130,7 +130,7 @@ jobs: with: node-version: '20' - run: npm install -g sim - - run: sim workflows run wf_7Yb2 --output json + - run: sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json env: SIM_API_KEY: ${{ secrets.SIM_API_KEY }} SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }} @@ -151,8 +151,8 @@ sim workflows list --profile prod Use workspace profiles when one personal key should target several workspaces: ```bash -sim profile add marketing --workspace ws_marketing -sim profile add support --workspace ws_support +sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d +sim profile add support --workspace e0d94b17-3c62-45af-9718-b6a2c8035f4e sim workflows list --profile marketing sim workflows list --profile support diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index b0b177f90f9..b5ba49ac7d4 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -28,14 +28,14 @@ sim profiles # list them; * marks the active one Add a profile for another workspace without creating or copying an API key: ```bash -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` ## Setting defaults ```bash sim configure --set-endpoint http://localhost:3000 --profile dev -sim configure --set-workspace ws_local --profile dev +sim configure --set-workspace 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 --profile dev sim configure --set-output json ``` @@ -76,16 +76,16 @@ repo: ```ini title="~/.sim/config" [default] endpoint = https://www.sim.ai -workspace = ws_abc123 +workspace = 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 output = table [profile dev] endpoint = http://localhost:3000 -workspace = ws_local +workspace = 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 [profile acme] auth_profile = default -workspace = ws_acme +workspace = 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -132,9 +132,9 @@ filesystem at all. Workspace-scoped commands need a workspace: ```bash -sim tables list --workspace ws_other -sim configure --set-workspace ws_abc123 -export SIM_WORKSPACE=ws_abc123 +sim tables list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 +export SIM_WORKSPACE=2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 ``` For a reusable selection, create a workspace profile backed by the current @@ -142,7 +142,7 @@ stored login: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme tables list ``` diff --git a/apps/docs/content/docs/en/cli/index.mdx b/apps/docs/content/docs/en/cli/index.mdx index ffe240c7b16..a9927e7d9e3 100644 --- a/apps/docs/content/docs/en/cli/index.mdx +++ b/apps/docs/content/docs/en/cli/index.mdx @@ -77,9 +77,9 @@ sim workflows list ``` ``` -ID NAME FOLDER DEPLOYED RUNS LAST RUN -wf_7Yb2 Refund triage /Support yes 412 2026-08-15 14:02:11 -wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 +ID NAME FOLDER DEPLOYED RUNS LAST RUN +3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 Refund triage /Support yes 412 2026-08-15 14:02:11 +b8c0d247-9e13-4a86-97f5-2ad4e1638c09 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ``` @@ -87,7 +87,7 @@ wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ### Run one ```bash -sim workflows run wf_7Yb2 --input '{"ticketId":"T-4821"}' +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"ticketId":"T-4821"}' ``` A workflow must be deployed before it can be run. Deploy from the editor, or @@ -106,8 +106,8 @@ sim [sub-resource] [arguments] [options] ```bash sim workflows list -sim tables rows query tbl_123 --limit 50 -sim knowledge documents upload kb_123 ./handbook.pdf +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 50 +sim knowledge documents upload 4c1b7f60-2d55-4a3e-9c18-70b6ea2f9d31 ./handbook.pdf ``` Resource groups are plural, and each also accepts its singular spelling — @@ -146,8 +146,10 @@ sim tables rows query --help | [`workflow-mcp-servers`](/cli/workflow-mcp-servers) | Publish workflows as MCP tools for outside agents | | [`meta`](/cli/meta) | Check what this API supports and which limits apply | -The [command reference](/cli/commands) documents every subcommand, argument, and -flag, and is generated from the CLI itself. +The [command overview](/cli/commands) has the global options and the commands +that take no resource; the [complete reference](/cli/reference) documents every +subcommand, argument, and flag on one page. Both are generated from the CLI +itself. ## Where to go next @@ -156,4 +158,5 @@ flag, and is generated from the CLI itself. - [Output formats](/cli/output) — `table`, `json`, `yaml`, and `text`, and when to use each - [Scripting](/cli/scripting) — piping, file inputs, exit codes, and automation recipes - [Troubleshooting](/cli/troubleshooting) — what each error means, and how to resolve it -- [Command reference](/cli/commands) — every command, argument, and flag +- [Command overview](/cli/commands) — global options, the command groups, and the commands that take no resource +- [Complete reference](/cli/reference) — every command, argument, and flag on a single page diff --git a/apps/docs/content/docs/en/cli/output.mdx b/apps/docs/content/docs/en/cli/output.mdx index 1c431f838d8..b85aa9cb431 100644 --- a/apps/docs/content/docs/en/cli/output.mdx +++ b/apps/docs/content/docs/en/cli/output.mdx @@ -15,7 +15,7 @@ Every command renders through the same four formats. Select one per command, save it to the profile, or set it in the environment: ```bash -sim tables get tbl_123 --output json +sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output json sim configure --set-output json SIM_OUTPUT=yaml sim logs list > logs.yaml ``` @@ -47,14 +47,14 @@ An absent value is an em-dash in `table` and an empty field in `text`. with span inputs, outputs, errors, timing, and cost: ```bash -sim logs get run_123 --trace +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --trace ``` `json` and `yaml` always carry the complete response, so `--trace` is a no-op there: ```bash -sim logs get run_123 --output json | jq '.traceSpans' +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json | jq '.traceSpans' ``` ## Exceptions @@ -66,6 +66,6 @@ configuration, not API data. so that it round-trips through `import`: ```bash -sim workflows export wf_123 > wf.json +sim workflows export 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 > wf.json sim workflows import --workflow @wf.json ``` diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 32be39572b9..0f10192c396 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -14,7 +14,7 @@ to read stdin. ```bash sim workflows import --workflow @wf.json -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json cat wf.json | sim workflows import --workflow @- ``` @@ -24,9 +24,9 @@ Primitive lists take space-separated values. With `@`, the file supplies one value per line: ```bash -sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids wf_3Qm8ZtLpR2yVnKd7BsXwC wf_5Hn1JvTqW9xUcMb4RzPgL --to Archive sim files mv --file-ids @file-ids.txt --to Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive +printf 'wf_3Qm8ZtLpR2yVnKd7BsXwC\nwf_5Hn1JvTqW9xUcMb4RzPgL\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects stay JSON. @@ -37,7 +37,7 @@ Arrays of objects stay JSON. groups of `{field, op, value}` conditions, nestable. ```bash -sim tables rows query tbl_123 \ +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --limit 50 @@ -50,7 +50,7 @@ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `--sort` is also JSON, an ordered list of keys: ```bash -sim tables rows query tbl_123 --sort '[{"field":"createdAt","direction":"desc"}]' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"createdAt","direction":"desc"}]' ``` ## Pagination @@ -68,8 +68,8 @@ Deletions require an explicit selector **and** `--yes`. There is no "delete everything" default: ```bash -sim tables rows batch-delete tbl_123 --row row_1 row_2 --yes -sim files delete file_123 --yes +sim tables rows batch-delete tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --row row_2f81c0a94db54e6f8a13c7e0526bd94a row_6b3e59d0af1c42d7b80e94f3a271c568 --yes +sim files delete wf_8Kd2NpVrY6zTfQa3XwBmS --yes ``` Without `--yes` the command explains what it would have destroyed and stops. @@ -92,7 +92,7 @@ Errors print one line to stderr, prefixed `Error:`, plus the API's error code an validation details when it supplies them. Failures are safe to branch on: ```bash -if ! sim workflows run wf_7Yb2 --output json > result.json; then +if ! sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json > result.json; then echo "run failed" >&2 exit 1 fi @@ -121,7 +121,7 @@ esac produce are simply omitted: ```bash -sim workflows run wf_7Yb2 --select-output agent_1.content --output json +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --select-output agent_1.content --output json ``` ## Polling a long run @@ -129,9 +129,9 @@ sim workflows run wf_7Yb2 --select-output agent_1.content --output json Start the run asynchronously, then poll its status: ```bash -run_id=$(sim workflows run wf_7Yb2 --async --output json | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --async --output json | jq -r '.runId') -until sim workflows runs get "$run_id" --workflow wf_7Yb2 --output json \ +until sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json \ | jq -e '.status | IN("completed","failed","cancelled")' > /dev/null; do sleep 5 done @@ -169,9 +169,9 @@ export SIM_API_KEY="${SIM_API_KEY:?missing}" export SIM_WORKSPACE="${SIM_WORKSPACE:?missing}" export SIM_OUTPUT=json -run_id=$(sim workflows run wf_7Yb2 --input '{"source":"nightly"}' | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' | jq -r '.runId') -if [ "$(sim workflows runs get "$run_id" --workflow wf_7Yb2 | jq -r '.status')" != "completed" ]; then +if [ "$(sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 | jq -r '.status')" != "completed" ]; then sim logs get "$run_id" >&2 exit 1 fi diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index c8ecdcef039..8bf2dfd58c0 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -53,8 +53,8 @@ Your shell consumed the quotes. Wrap the whole value in single quotes, or read i from a file: ```bash -sim tables rows query tbl_123 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json ``` ## A value looks truncated @@ -63,7 +63,7 @@ sim tables rows query tbl_123 --filter @filter.json switch to a machine format to see it in full: ```bash -sim logs get run_123 --output json +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json ``` ## `sim files get` refuses to print to the terminal @@ -72,8 +72,8 @@ Writing arbitrary binary to an interactive terminal can corrupt it, so non-text content has to go to a file or a pipe: ```bash -sim files get file_123 -o ./image.png -sim files get file_123 | shasum +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS -o ./image.png +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS | shasum ``` ## A stored output format is invalid diff --git a/package.json b/package.json index 1b51b3735f1..10fd48696c3 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts", "test:tool-registry-boundary": "bunx vitest run scripts/check-tool-registry-boundary.test.ts", "test:tool-request-boundary": "bunx vitest run scripts/check-tool-request-boundary.test.ts", - "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-docs.test.ts", + "test:generators": "bunx vitest run scripts/generate-v2-cli-api.test.ts scripts/generate-cli-docs.test.ts scripts/generate-docs.test.ts", "format": "turbo run format", "format:check": "turbo run format:check", "lint": "turbo run lint", diff --git a/scripts/generate-cli-docs.test.ts b/scripts/generate-cli-docs.test.ts new file mode 100644 index 00000000000..84a267ed2d9 --- /dev/null +++ b/scripts/generate-cli-docs.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { describeOption, formatDefault, GUIDE_PAGES, OUTPUT_DIR } from './generate-cli-docs' + +/** `describeOption` reads only these two fields; the rest of the Option is irrelevant. */ +function option(fields: { description?: string; defaultValue?: unknown }) { + return fields as Parameters[0] +} + +function guide(page: string): string { + return readFileSync(path.join(OUTPUT_DIR, `${page}.mdx`), 'utf8') +} + +describe('an option default rendered into the reference table', () => { + /** + * A repeatable flag's Commander default is `[]` — the accumulator its + * collector appends to, not a value anyone would type. `String([])` is the + * empty string, so the table carried a dangling "Defaults to ``." + */ + it('omits the clause for a repeatable flag with an empty accumulator', () => { + const cell = describeOption( + option({ + description: 'Only follow runs of this workflow (repeatable)', + defaultValue: [], + }) + ) + expect(cell).toBe('Only follow runs of this workflow (repeatable).') + expect(cell).not.toContain('Defaults to') + }) + + it('omits the clause for an empty-string default', () => { + expect(describeOption(option({ description: 'Folder path', defaultValue: '' }))).not.toContain( + 'Defaults to' + ) + }) + + /** + * The guard is on emptiness, not falsiness: `false` and `0` are defaults a + * caller has to be told about, and a `!value` check would drop both. + */ + it('still states a false default', () => { + expect( + describeOption(option({ description: 'Follow the stream', defaultValue: false })) + ).toContain('Defaults to `false`.') + }) + + it('still states a zero default', () => { + expect(describeOption(option({ description: 'Retry attempts', defaultValue: 0 }))).toContain( + 'Defaults to `0`.' + ) + }) + + it('states a scalar default', () => { + expect( + describeOption(option({ description: 'Response detail', defaultValue: 'full' })) + ).toContain('Defaults to `full`.') + }) + + it('joins a non-empty array default', () => { + expect(formatDefault(['a', 'b'])).toBe('a, b') + expect(describeOption(option({ description: 'Levels', defaultValue: ['a', 'b'] }))).toContain( + 'Defaults to `a, b`.' + ) + }) +}) + +describe('links between the CLI docs pages', () => { + /** + * `/cli/reference` is the only page documenting every flag, and nothing but + * itself linked to it — the index sent "every subcommand" readers to + * `/cli/commands`, which is the overview. + */ + it('links the complete reference from a page other than itself', () => { + expect(guide('index')).toContain('](/cli/reference)') + }) + + it('attaches the every-subcommand claim to the reference, not the overview', () => { + const claim = guide('index') + .split('\n\n') + .find((block) => block.includes('every\nsubcommand') || block.includes('every subcommand')) + expect(claim).toBeDefined() + expect(claim).toContain('/cli/reference') + }) +}) + +/** + * The id shapes the CLI states in its own root help (`HELP_EPILOGUE`, + * `packages/sim-cli/src/program.ts`): workflow, knowledge-base, workspace and + * run ids are bare UUIDs, table ids carry `tbl_`, file ids carry `wf_`. + * + * The guides are hand-written and the generator neither writes nor inspects + * them, so nothing else stops their placeholder ids from teaching a scheme the + * API does not use — which is how `wf_7Yb2` came to name a workflow ~10 times. + */ +const FOREIGN_ID_PREFIXES = ['ws_', 'kb_', 'run_', 'file_', 'doc_', 'org_'] as const + +describe('placeholder ids in the hand-written guides', () => { + it('uses no prefix the API never issues', () => { + // No `\b` before the prefix: `printf 'wf_…\nfile_2\n'` carries a literal + // `\n`, and a word-anchored sweep reads straight past the `file_2` after it. + const candidate = new RegExp(`(.?)(${FOREIGN_ID_PREFIXES.join('|')})([A-Za-z0-9]+)(.?)`, 'g') + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + guide(page) + .split('\n') + .forEach((line, index) => { + // A literal `\n` inside a quoted `printf` separates two ids, so it has + // to read as a boundary — otherwise the sweep sees `nfile_2`, takes + // the `n` for a word character, and skips the second id entirely. + const scan = line.replace(/\\[nrt]/g, ' ') + for (const [, before, prefix, rest, after] of scan.matchAll(candidate)) { + // A shell variable is read with `$` and written with `=`; the + // recipes name one `run_id`, which is not an id placeholder. + if (before === '$' || before === '{' || after === '=') continue + if (/[\w-]/.test(before)) continue + offenders.push(`${page}.mdx:${index + 1}: ${prefix}${rest}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + /** `wf_` is the FILE prefix, so it must never sit in a workflow argument. */ + it('never spells a workflow id with the file prefix', () => { + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + const lines = guide(page).split('\n') + lines.forEach((line, index) => { + if (/(?:sim )?workflows? [a-z-]+ wf_|--workflow wf_/.test(line)) { + offenders.push(`${page}.mdx:${index + 1}: ${line.trim()}`) + } + }) + } + expect(offenders).toEqual([]) + }) + + /** A prefixed id shorter than the real thing teaches a shape that never appears. */ + it('writes prefixed ids at their real length', () => { + const offenders: string[] = [] + for (const page of GUIDE_PAGES) { + const text = guide(page) + for (const match of text.matchAll(/\b(tbl_|row_)([A-Za-z0-9]+)/g)) { + if (match[2].length !== 32) offenders.push(`${page}.mdx: ${match[0]}`) + } + for (const match of text.matchAll(/\bwf_([A-Za-z0-9_-]+)/g)) { + if (match[1].length < 20) offenders.push(`${page}.mdx: ${match[0]}`) + } + } + expect(offenders).toEqual([]) + }) +}) diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index ff4023cc476..150bdcf8a4b 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -24,7 +24,7 @@ import { V2_OPERATIONS } from '../packages/sim-cli/src/generated/v2-api' import { buildProgram } from '../packages/sim-cli/src/program' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const OUTPUT_DIR = path.join(ROOT, 'apps/docs/content/docs/en/cli') +export const OUTPUT_DIR = path.join(ROOT, 'apps/docs/content/docs/en/cli') /** Commander's synthetic help command is not part of the documented surface. */ const HELP_COMMAND = 'help' @@ -38,7 +38,7 @@ const HELP_COMMAND = 'help' * these, so they are listed — the same guard `scripts/generate-docs.ts` uses for * its hand-authored integration pages. */ -const GUIDE_PAGES = [ +export const GUIDE_PAGES = [ 'index', 'authentication', 'configuration', @@ -201,14 +201,32 @@ function asSentence(value: string): string { return /[.!?]$/.test(value) ? value : `${value}.` } +/** + * Renders a default value for prose, or `''` when there is nothing to state. + * + * A repeatable flag's Commander default is `[]` — the empty accumulator its + * collector appends to, not a value anyone would type — and `String([])` is the + * empty string, which rendered as a dangling "Defaults to ``." The check is on + * emptiness rather than falsiness: `false` and `0` are real defaults a caller + * needs stated, and a `!value` guard would silently drop both. + */ +export function formatDefault(value: unknown): string { + if (Array.isArray(value)) { + return value.length > 0 ? value.map(String).join(', ') : '' + } + const text = String(value) + return text.trim() ? text : '' +} + /** Returns a table-ready cell: escaped prose, with code spans left intact. */ -function describeOption(option: Command['options'][number]): string { +export function describeOption(option: Command['options'][number]): string { const parts = [asSentence(escapeCell(stripRequiredSuffix(option.description || '')))] if (option.argChoices && option.argChoices.length > 0) { parts.push(`Accepted values: ${option.argChoices.map(code).join(', ')}.`) } if (option.defaultValue !== undefined) { - parts.push(`Defaults to ${code(String(option.defaultValue))}.`) + const fallback = formatDefault(option.defaultValue) + if (fallback) parts.push(`Defaults to ${code(fallback)}.`) } const description = parts.filter(Boolean).join(' ') return description || '—' @@ -705,4 +723,6 @@ function main(): void { ) } -main() +// Guarded so the pure helpers above can be imported by tests without the +// generator rewriting the docs as a side effect of the import. +if (import.meta.main) main() diff --git a/scripts/generate-v2-cli-api.test.ts b/scripts/generate-v2-cli-api.test.ts index bc994851973..84fcfaabfbc 100644 --- a/scripts/generate-v2-cli-api.test.ts +++ b/scripts/generate-v2-cli-api.test.ts @@ -1,6 +1,11 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { z } from 'zod' -import { CLI_MANAGED_HEADERS, renderSlotMap } from './generate-v2-cli-api' +import { CLI_MANAGED_HEADERS, loadSummaries, renderSlotMap } from './generate-v2-cli-api' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') describe('a field the contract types as nullable', () => { /** @@ -51,3 +56,60 @@ describe('request headers reaching the CLI as flags', () => { expect(map).toContain('upload-token') }) }) + +/** + * Reads the denial sentences out of `openapi/shared.ts` as source text. + * + * The generator itself imports that module, but it resolves through the `@/` + * alias, which the root vitest run has no resolver for. Parsing the literal + * keeps the test bound to the same single source of truth: reword the sentence + * and this recomputes the expected set, so a generator holding a stale copy of + * it goes red instead of silently unmarking a family. + */ +function personalKeyMarkers(): string[] { + const source = readFileSync( + path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts'), + 'utf8' + ) + const markers = [...source.matchAll(/export const (WORKSPACE_API_KEY_DENIED\w*) =\s*'([^']+)'/g)] + .filter(([, name]) => name.startsWith('WORKSPACE_API_KEY_DENIED')) + .map(([, , sentence]) => sentence) + expect(markers.length).toBeGreaterThan(0) + return markers +} + +function generatedSource(): string { + return readFileSync(path.join(ROOT, 'packages/sim-cli/src/generated/v2-api.ts'), 'utf8') +} + +/** The body of one entry in the emitted `V2_OPERATIONS` table. */ +function generatedEntry(source: string, name: string): string { + const match = source.match(new RegExp(`\\n ${name}: \\{([\\s\\S]*?)\\n \\},`)) + if (!match) throw new Error(`${name} is not in the generated operation table`) + return match[1] +} + +describe('operations that refuse a workspace API key', () => { + /** + * A count, not just named operations: pinning two of them would let a reword + * confined to one contract family silently unmark every other one while the + * pinned pair stayed green. + */ + it('emits the marker for every operation the specs say refuses one', () => { + const marked = [...loadSummaries(personalKeyMarkers()).values()].filter( + (doc) => doc.personalKeyOnly + ) + expect(marked.length).toBeGreaterThan(0) + expect(generatedSource().match(/personalKeyOnly: true/g)?.length ?? 0).toBe(marked.length) + }) + + it('marks restricted operations and leaves workspace-key-capable siblings alone', () => { + const source = generatedSource() + for (const name of ['listMcpServerTools', 'listSecrets', 'undeployWorkflow']) { + expect(generatedEntry(source, name)).toContain('personalKeyOnly: true') + } + for (const name of ['listMcpServers', 'getMcpServer', 'listWorkflows']) { + expect(generatedEntry(source, name)).not.toContain('personalKeyOnly') + } + }) +}) diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index 608f47bb62c..d8edc0e1f36 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -62,17 +62,54 @@ function specFiles(): string[] { .sort() } +/** What the OpenAPI specs say about one operation, beyond its request shape. */ +export interface OperationDoc { + /** The spec's one-line summary, used as the command's `--help` description. */ + summary?: string + /** + * The operation refuses a workspace API key, per its `description`. + * + * Carried so `--help` can say so before the request goes out; without it the + * caller learns the restriction from a `403` after the fact. + */ + personalKeyOnly?: true +} + /** - * `METHOD /api/v2/{id}/…` → the spec's one-line summary. + * The description sentences that mark an operation as personal-key-only. + * + * Read out of `apps/sim/lib/api/contracts/v2/openapi/shared.ts` at generation + * time rather than restated here, so rewording the sentence there cannot leave + * the marker silently unemitted. The import is lazy because that module + * resolves through the `@/` alias, which exists under `bun` but not under the + * root `vitest` that imports this file's pure helpers. + */ +export async function loadPersonalKeyMarkers(): Promise { + const shared: Record = await import( + path.join(ROOT, 'apps/sim/lib/api/contracts/v2/openapi/shared.ts') + ) + const markers = [shared.WORKSPACE_API_KEY_DENIED, shared.WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND] + for (const marker of markers) { + if (typeof marker !== 'string' || !marker.trim()) { + throw new Error('openapi/shared.ts no longer exports the workspace-key denial sentences') + } + } + return markers as string[] +} + +/** + * `METHOD /api/v2/{id}/…` → what the specs document about that operation. * * The contracts carry validation, not prose, so `--help` text has to come from * somewhere else. The specs already hold a hand-written summary per operation * and `check:openapi` guarantees every contract has one, so reading them here * reuses documentation that is already written and already verified rather than - * inventing a second place to describe the same endpoint. + * inventing a second place to describe the same endpoint. The longer + * `description` is read for the same reason — it is where the workspace-key + * denial is already stated. */ -function loadSummaries(): Map { - const summaries = new Map() +export function loadSummaries(personalKeyMarkers: readonly string[]): Map { + const docs = new Map() for (const file of specFiles()) { let spec: Record @@ -86,15 +123,23 @@ function loadSummaries(): Map { for (const [specPath, methods] of Object.entries(spec.paths ?? {})) { for (const [method, operation] of Object.entries(methods as Record)) { - const summary = operation?.summary - if (typeof summary === 'string') { - summaries.set(`${method.toUpperCase()} ${specPath}`, summary) + const doc: OperationDoc = {} + if (typeof operation?.summary === 'string') doc.summary = operation.summary + const description = operation?.description + if ( + typeof description === 'string' && + personalKeyMarkers.some((marker) => description.includes(marker)) + ) { + doc.personalKeyOnly = true + } + if (doc.summary || doc.personalKeyOnly) { + docs.set(`${method.toUpperCase()} ${specPath}`, doc) } } } } - return summaries + return docs } /** @@ -470,9 +515,8 @@ export function renderSlotMap( return `{\n${lines.join('\n')}\n${indent}}` } -function render(operations: Operation[]): string { +function render(operations: Operation[], docs: Map): string { const out: string[] = [] - const summaries = loadSummaries() out.push('/**') out.push(' * GENERATED FILE — DO NOT EDIT.') @@ -526,6 +570,9 @@ function render(operations: Operation[]): string { out.push(' *') out.push(" * `summary` is the operation's one-line description, lifted from the OpenAPI") out.push(' * specs so `--help` reuses prose that is already written and already checked.') + out.push(' *') + out.push(' * `personalKeyOnly` marks an operation whose spec description says a workspace') + out.push(' * API key is rejected, so `--help` can say so before the request is sent.') out.push(' */') out.push('export const V2_OPERATIONS = {') for (const op of operations) { @@ -544,10 +591,11 @@ function render(operations: Operation[]): string { } out.push(` responseMode: '${op.contract.response.mode}',`) // OpenAPI writes `{id}` where the contract writes `[id]`. - const summary = summaries.get( + const doc = docs.get( `${op.contract.method} ${op.contract.path.replace(/\[([^\]]+)\]/g, '{$1}')}` ) - if (summary) out.push(` summary: ${JSON.stringify(summary)},`) + if (doc?.summary) out.push(` summary: ${JSON.stringify(doc.summary)},`) + if (doc?.personalKeyOnly) out.push(` personalKeyOnly: true,`) for (const slot of ['query', 'body'] as const) { const map = renderSlotMap(op.contract[slot], ' ') if (map) out.push(` ${slot}: ${map},`) @@ -608,7 +656,7 @@ async function main() { const args = new Set(process.argv.slice(2)) const operations = await collectOperations() - const generated = format(render(operations)) + const generated = format(render(operations, loadSummaries(await loadPersonalKeyMarkers()))) if (args.has('--check')) { let current = '' From b6623568724716814302ad6b57b598f677c9dc45 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:18:49 -0700 Subject: [PATCH 09/15] fix(cli): stop a page-size default capping a destructive filter Every request field named limit inherited the pager's default of 100, but only a cursor-paginated command interprets that flag. The two filter-based row mutations declare no cursor, so the default went onto the wire as a row cap: a filter matching 250 rows deleted 100, exited 0, and said nothing - while the confirmation the user had just answered promised every matching row. The flag's own help offered 0 for everything, which those endpoints reject; the unbounded form is the field being absent. The pager's default now applies only where the pager runs, and the tests pin the omission on the request body rather than in help text. A cap typed alongside an explicit row list was silently ignored; it is now refused on the client, where refusing costs nothing to already-installed versions. Lists also truncated at a hundred with no signal in any format, and the two inventory endpoints that do report truncation had that field dropped on the way out - so a caller reconciling against a clipped list could not tell. One note now goes to stderr while stdout stays a bare array, and a flag raised on a later page survives the fold. Also: a folder whose name contains the separator no longer prints a path that resolves to a different folder; validation errors name the flag the user typed instead of the wire field; an unknown subcommand with --help exits non-zero instead of printing the parent's help; a fractional or negative page size is refused rather than floored; an empty query filter is refused rather than silently returning everything; and the two spellings of the missing-workspace message became one. --- packages/sim-cli/src/program.ts | 7 +- packages/sim-cli/src/runtime/build.test.ts | 329 ++++++++++++++++++- packages/sim-cli/src/runtime/build.ts | 62 +++- packages/sim-cli/src/runtime/execute.test.ts | 101 +++++- packages/sim-cli/src/runtime/execute.ts | 117 ++++++- packages/sim-cli/src/runtime/naming.test.ts | 95 ++++++ packages/sim-cli/src/runtime/naming.ts | 140 ++++++++ packages/sim-cli/src/runtime/options.ts | 36 +- packages/sim-cli/src/runtime/request.test.ts | 31 ++ packages/sim-cli/src/runtime/request.ts | 68 +++- packages/sim-cli/src/runtime/result.test.ts | 61 +++- packages/sim-cli/src/runtime/result.ts | 95 +++++- packages/sim-cli/src/runtime/types.ts | 7 + 13 files changed, 1109 insertions(+), 40 deletions(-) create mode 100644 packages/sim-cli/src/runtime/naming.test.ts create mode 100644 packages/sim-cli/src/runtime/naming.ts diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index d1945b398be..999350d71cd 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -5,7 +5,11 @@ import { attachCredentialCommands } from './commands/credentials' import { attachProtocolCommands } from './commands/protocol/index' import { attachSecretCommands } from './commands/secrets' import { OUTPUT_FORMATS } from './config/index' -import { assertNoReservedProgramFlags, buildGeneratedCommands } from './runtime/build' +import { + assertNoReservedProgramFlags, + buildGeneratedCommands, + refuseHelpAfterUnknownCommand, +} from './runtime/build' import { CLI_VERSION } from './version' /** Root program description, shared by `--help` and the generated docs. */ @@ -145,6 +149,7 @@ export function buildProgram(options: { version?: boolean } = {}): Command { program.addHelpText('after', HELP_EPILOGUE) + refuseHelpAfterUnknownCommand(program) assertNoReservedProgramFlags(program) return program diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index cfb5b671bbd..de59df442f8 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { beforeEach, describe, expect, it, vi } from 'vitest' import { V2_OPERATIONS } from '../generated/v2-api' +import { SimApiError } from '../http/client' import { buildProgram } from '../program' import { assertNoReservedProgramFlags, buildGeneratedCommands } from './build' import { kebab } from './derive' @@ -19,6 +20,10 @@ import type { OperationSpec } from './types' * catch that class of bug. */ +/** `SimClient.requireWorkspace`'s message, verbatim, for the mocked client. */ +const NO_WORKSPACE_FOR_PROFILE = + 'No workspace set for profile "default". Pass --workspace, or run: sim configure --profile default --set-workspace ' + const { mockRequest, output, profileState } = vi.hoisted(() => ({ mockRequest: vi.fn(), output: { format: 'json' }, @@ -30,7 +35,9 @@ vi.mock('../context', () => ({ client: { request: mockRequest, requireWorkspace: () => { - if (!profileState.workspaceId) throw new Error('workspace required') + // The client's own wording, so a command that skips `requireWorkspace` + // is visible here rather than passing on a placeholder. + if (!profileState.workspaceId) throw new Error(NO_WORKSPACE_FOR_PROFILE) return profileState.workspaceId }, }, @@ -192,6 +199,40 @@ describe('commands parsed through commander', () => { expect(program().commands.some((command) => command.name() === 'folders')).toBe(false) }) + /** + * ~59 v2 operations refuse a workspace API key. The restriction is stated in + * the OpenAPI description, and before the generator carried it into the + * operation table `--help` advertised them identically to their + * workspace-key-capable siblings — the caller met the rule as a `403` only + * after the request had gone out. + */ + describe('a command whose operation refuses a workspace API key', () => { + it('says so in the help line it falls back to from the spec summary', () => { + expect(commandAt('secrets', 'list').helpInformation()).toContain( + '(personal API key required)' + ) + expect(commandAt('mcp-servers', 'tools', 'list').description()).toContain( + '(personal API key required)' + ) + }) + + /** + * The suffix goes after the whole `describe ?? summary ?? METHOD path` + * chain. Folding it into the summary branch would drop it on every command + * carrying a hand-written `describe`, which is most of the restricted ones. + */ + it('says so on a command carrying a hand-written describe', () => { + expect(commandAt('workflows', 'undeploy').description()).toBe( + 'Take a workflow out of deployment (personal API key required)' + ) + }) + + it('leaves a workspace-key-capable sibling unsuffixed', () => { + expect(commandAt('mcp-servers', 'list').description()).not.toContain('personal API key') + expect(commandAt('workflows', 'list').description()).not.toContain('personal API key') + }) + }) + it('describes generated resource and sub-resource groups', () => { expect(commandAt('tables').description()).toBe('Manage tables') expect(commandAt('tables', 'rows').description()).toBe('Manage table rows') @@ -295,7 +336,7 @@ describe('commands parsed through commander', () => { profileState.workspaceId = null const [, unconfiguredAccountOptions] = await run(['billing', 'status', '--all-workspaces']) expect(unconfiguredAccountOptions.query).toEqual({}) - await expect(run(['billing', 'status'])).rejects.toThrow('workspace required') + await expect(run(['billing', 'status'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) profileState.workspaceId = 'ws_local' await expect( run(['--workspace', 'ws_other', 'billing', 'status', '--all-workspaces']) @@ -541,10 +582,12 @@ describe('commands parsed through commander', () => { }) expect(workspacePath).toBe('/api/v2/workspaces/ws_local') + // The workspace arrives as a path parameter here, and used to skip + // `requireWorkspace` — so this one precondition had two wordings, and the + // one these two commands printed never named the profile. profileState.workspaceId = null - await expect(run(['workspace', 'get'])).rejects.toThrow( - 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' - ) + await expect(run(['workspace', 'get'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) + await expect(run(['workspace', 'members'])).rejects.toThrow(NO_WORKSPACE_FOR_PROFILE) profileState.workspaceId = 'ws_local' const membersHelp = commandAt('workspaces', 'members').helpInformation() @@ -555,6 +598,27 @@ describe('commands parsed through commander', () => { expect(membersOptions.query).toEqual({ limit: 100, cursor: null }) }) + /** + * The server names its own fields — right for an OpenAPI reader, untypeable + * here: `drop includeJobRuns` names no flag this CLI has. + */ + it('restates a rejected wire field as the flag the caller typed', async () => { + mockRequest.mockReset() + mockRequest.mockRejectedValue( + new SimApiError( + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"', + 400, + 'BAD_REQUEST', + [{ path: ['sortBy'], message: 'sortBy: drop includeJobRuns' }] + ) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await expect( + program().parseAsync(['node', 'sim', 'logs', 'list', '--include-job-runs']) + ).rejects.toThrow(/drop --include-job-runs/) + }) + it('comma-joins a repeated list flag', async () => { const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) @@ -638,6 +702,9 @@ describe('commands parsed through commander', () => { const help = commandAt('files', 'move').helpInformation() expect(help).toContain('--file-ids ') expect(help).toMatch(/space-separated.*@path.*one\s+value\s+per\s+line/s) + // The escape a value that genuinely starts with `@` needs, said where the + // caller reads before typing rather than only after the read fails. + expect(help).toContain('@@') }) it('advertises the file-content encoding choices', () => { @@ -1125,6 +1192,44 @@ describe('pagination slot', () => { expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain('fetched 1') }) + /** + * `parseInt` truncated the value before it was checked, so a fractional was + * silently floored and `-0.5` parsed to `-0` — not less than zero, and then + * equal to the `0` that means "everything". Both walked a workspace the + * caller had asked to cap. + */ + it('refuses a limit that is not a whole number, before any request', async () => { + for (const value of ['-0.5', '-0.9', '3.9', '1.5', '', ' ']) { + mockRequest.mockReset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + await expect( + program().parseAsync(['node', 'sim', 'files', 'list', '--limit', value]) + ).rejects.toThrow(/--limit must be a whole number of 0 or more/) + expect(mockRequest).not.toHaveBeenCalled() + } + }) + + it('reads a limit the way the caller wrote it', async () => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ + data: Array.from({ length: 20 }, (_row, index) => ({ id: `f_${index}` })), + nextCursor: null, + }) + const printed: string[] = [] + vi.spyOn(console, 'log').mockImplementation((line: string) => { + printed.push(line) + }) + + // `parseInt(…, 10)` stopped at the `x` and read 0, which meant everything. + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '0x10']) + expect(JSON.parse(printed[0])).toHaveLength(16) + + printed.length = 0 + // And stopped at the `e`, reading a single row where 1000 was asked for. + await program().parseAsync(['node', 'sim', 'files', 'list', '--limit', '1e3']) + expect(JSON.parse(printed[0])).toHaveLength(20) + }) + it('uses a valid per-page size for unlimited and large totals', async () => { for (const requested of ['0', '250']) { mockRequest.mockReset() @@ -1342,6 +1447,101 @@ describe('bodies and fields the generator cannot flatten', () => { }) }) + /** + * The pager's flag was handed to any field named `limit`, so a bulk mutation + * that does not paginate carried commander's `100` default into its body: a + * filter matching 250 rows deleted 100, exited 0, and said nothing. + */ + describe('a row cap on a mutation that does not paginate', () => { + const FILTER = '{"all":[{"field":"status","op":"eq","value":"active"}]}' + + it('leaves the cap off the wire entirely when it is not typed', async () => { + const [, omitted] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + FILTER, + '--yes', + ]) + expect(omitted.body).not.toHaveProperty('limit') + + const [, updated] = await run([ + 'tables', + 'rows', + 'batch-update', + 'tbl_1', + '--filter', + FILTER, + '--data', + '{"status":"done"}', + '--yes', + ]) + expect(updated.body).not.toHaveProperty('limit') + }) + + it('sends the cap the caller typed, unrounded by the pager', async () => { + const [, given] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--filter', + FILTER, + '--limit', + '3', + '--yes', + ]) + expect(given.body).toMatchObject({ limit: 3 }) + }) + + it('documents the cap as the contract states it, without the pager default', () => { + for (const command of ['batch-delete', 'batch-update']) { + const help = commandAt('tables', 'rows', command).helpInformation() + expect(help).toContain('Maximum matching rows to') + expect(help).not.toContain('Maximum items to return') + expect(help).not.toMatch(/--limit[^\n]*default/) + // The help block wraps, so the sentence is matched across the break. + expect(help).toMatch(/caps a --filter\s+match only/) + expect(help).toMatch(/0\s+is not accepted/) + } + }) + + it('refuses a cap typed alongside the id list that supersedes it', async () => { + await expect( + run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--row', + 'row_1', + 'row_2', + '--limit', + '1', + '--yes', + ]) + ).rejects.toThrow(/--limit caps a --filter match .* --row list; pass one, not both/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('still deletes an explicit id list, with no cap on the wire', async () => { + const [, options] = await run([ + 'tables', + 'rows', + 'batch-delete', + 'tbl_1', + '--row', + 'row_1', + 'row_2', + '--yes', + ]) + expect(options.body).toMatchObject({ rowIds: ['row_1', 'row_2'] }) + expect(options.body).not.toHaveProperty('limit') + }) + }) + it('still gives paginated lists their numeric --limit', async () => { const [, options] = await run(['files', 'list', '--limit', '7']) expect(options.query).toMatchObject({ limit: 7 }) @@ -1715,3 +1915,122 @@ describe('the billing ledger a key can see', () => { expect(lines[0].split('\t')[2]).toBe('workflow') }) }) + +describe('a list that is not the whole answer', () => { + /** Captures stderr for one invocation, in one output format. */ + async function noteFor( + format: 'table' | 'text' | 'json', + argv: string[], + response: unknown + ): Promise { + const errors: string[] = [] + const written = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + errors.push(String(chunk)) + return true + }) + output.format = format + try { + await run(argv, response) + } finally { + output.format = 'json' + written.mockRestore() + } + return errors.join('') + } + + /** + * `sim tools list` answered 100 rows of 4708 with exit 0 and an empty + * stderr, in every format — a clipped inventory that read as the inventory. + */ + it('says so, once, on stderr, in every format', async () => { + for (const format of ['table', 'text', 'json'] as const) { + const note = await noteFor(format, ['tools', 'list', '--limit', '2'], { + data: [{ id: 'a' }, { id: 'b' }], + nextCursor: 'c1', + }) + + expect(note).toContain('more results exist') + expect(note).toContain('--limit 0') + } + }) + + it('says nothing when the list is complete', async () => { + const note = await noteFor('table', ['tools', 'list', '--limit', '2'], { + data: [{ id: 'a' }, { id: 'b' }], + nextCursor: null, + }) + + expect(note).not.toContain('more results exist') + }) + + /** + * The server clips an inventory itself and says so on the envelope, which the + * CLI dropped: `--output json` prints `data` alone, so a reconciling caller + * could not tell a clipped list from a complete one. + */ + it('carries a truncation the server stated on the envelope', async () => { + const paged = await noteFor('json', ['workflow-mcp-servers', 'list'], { + data: [{ id: 'srv_1' }], + nextCursor: null, + toolNamesTruncated: true, + }) + expect(paged).toContain('tool names truncated') + + const unpaged = await noteFor('json', ['workflow-mcp-servers', 'tools', 'list', 'srv_1'], { + data: [{ toolName: 't' }], + nextCursor: null, + truncated: true, + }) + expect(unpaged).toContain('truncated') + }) + + it('says nothing when the server states the list is whole', async () => { + const note = await noteFor('json', ['workflow-mcp-servers', 'list'], { + data: [{ id: 'srv_1' }], + nextCursor: null, + toolNamesTruncated: false, + }) + + expect(note).not.toContain('truncated') + }) + + /** A flag raised on a later page is the same fact, and used to be lost. */ + it('carries a truncation stated on a page after the first', async () => { + mockRequest.mockReset() + mockRequest + .mockResolvedValueOnce({ data: [{ id: 'a' }], nextCursor: 'c1', toolNamesTruncated: false }) + .mockResolvedValueOnce({ data: [{ id: 'b' }], nextCursor: null, toolNamesTruncated: true }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + const errors: string[] = [] + const written = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + errors.push(String(chunk)) + return true + }) + + try { + await program().parseAsync(['node', 'sim', 'workflow-mcp-servers', 'list', '--limit', '0']) + } finally { + written.mockRestore() + } + + expect(errors.join('')).toContain('tool names truncated') + }) + + it('leaves the rows on stdout exactly as they were', async () => { + const lines: string[] = [] + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: [{ id: 'a' }, { id: 'b' }], nextCursor: 'c1' }) + vi.spyOn(console, 'log').mockImplementation((line: string) => { + lines.push(line) + }) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + await program().parseAsync(['node', 'sim', 'tools', 'list', '--limit', '2']) + + expect(JSON.parse(lines.join('\n'))).toEqual([{ id: 'a' }, { id: 'b' }]) + }) +}) diff --git a/packages/sim-cli/src/runtime/build.ts b/packages/sim-cli/src/runtime/build.ts index 174548e552e..0f778e56347 100644 --- a/packages/sim-cli/src/runtime/build.ts +++ b/packages/sim-cli/src/runtime/build.ts @@ -4,6 +4,7 @@ import type { CommandSpec, CommandVariantSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { deriveCommandPath } from './derive' import { executeOperation } from './execute' +import { retypeApiError } from './naming' import { addOperationOptions } from './options' import { warnRenamedCommand } from './renamed' import { @@ -127,6 +128,54 @@ export function assertNoReservedProgramFlags(program: Command): void { for (const child of program.commands) walk(child, []) } +/** + * Refuses `--help` typed after a command that does not exist. + * + * Commander answers a help flag before it looks at the operands, so `sim + * workspaces zzzz --help` printed the group's help and exited `0` while the + * same words without the flag exit `1`. A capability probe reading the exit + * code therefore concluded a command exists when it does not. + * + * Only pure dispatchers are guarded. A command that takes arguments or acts on + * its own (`sim files restore `, `sim profiles`) legitimately sees an + * operand it did not register as a subcommand, and refusing there would break + * `--help` on argv the CLI accepts. + */ +export function refuseHelpAfterUnknownCommand(program: Command): void { + const walk = (command: Command): void => { + // Neither the action handler nor `unknownCommand` is in commander's + // typings, for the same reason `rawArgs` is not — reaching for them is what + // keeps this message, its "did you mean" suggestion, and its error code + // identical to the non-help path. + const internals = command as Command & { + _actionHandler?: unknown + unknownCommand: () => never + } + const dispatchesOnly = + command.commands.length > 0 && + !internals._actionHandler && + command.registeredArguments.length === 0 + + if (dispatchesOnly) { + const known = new Set(['help']) + for (const child of command.commands) { + known.add(child.name()) + for (const alias of child.aliases()) known.add(alias) + } + // `beforeHelp` fires inside `outputHelp()`, before a byte is written, and + // by then commander has already assigned the parsed operands. + command.on('beforeHelp', () => { + const first = command.args[0] + if (first === undefined || first.startsWith('-') || known.has(first)) return + internals.unknownCommand() + }) + } + + for (const child of command.commands) walk(child) + } + walk(program) +} + function configureOperation( command: Command, operation: V2OperationName, @@ -213,13 +262,22 @@ function configureOperation( } } - command.description( + // Appended after the whole fallback chain, not inside the summary branch: a + // command with a hand-written `describe` needs the restriction stated just as + // much as one falling back to the spec summary. + const described = spec.describe ?? operationSpec.summary ?? `${operationSpec.method} ${operationSpec.path}` + command.description( + operationSpec.personalKeyOnly ? `${described} (personal API key required)` : described ) addOperationOptions(command, operation, spec, operationSpec) assertNoReservedFlags(command, operation) + // The last frame that still knows which operation ran, and so the only one + // that can say `--include-job-runs` where the server said `includeJobRuns`. command.action((...invocation: unknown[]) => - executeOperation(operation, spec, operationSpec, invocation) + executeOperation(operation, spec, operationSpec, invocation).catch((error) => { + throw retypeApiError(error, operation, spec, operationSpec) + }) ) return command } diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index 0347b7015f4..8750c52ded4 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -341,6 +341,98 @@ describe('a bulk call that changed nothing', () => { }) }) +const BULK_UPDATE_CHUNKS: OperationSpec = { + method: 'PATCH', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks', + pathParams: ['knowledgeBaseId', 'documentId'], + body: {}, +} + +const ADD_WORKSPACE_FILES: OperationSpec = { + method: 'POST', + path: '/api/v2/knowledge/[knowledgeBaseId]/documents/from-workspace-files', + pathParams: ['knowledgeBaseId'], + body: {}, +} + +/** + * Two more endpoints that answer `200` having done nothing at all: a chunk + * update where no listed id matched, and an indexing call where every file + * failed. Both printed their own report of the miss and exited `0`, so + * `sim … && next-step` ran on the strength of a no-op. + */ +describe('a bulk call that touched nothing', () => { + function updateChunks(flags: Record) { + const host = new Command('leaf') + return executeOperation('bulkUpdateKnowledgeChunks', {}, BULK_UPDATE_CHUNKS, [ + 'kb_1', + 'doc_1', + flags, + host, + ]) + } + + function indexFiles(flags: Record) { + const host = new Command('leaf') + return executeOperation('addWorkspaceFilesToKnowledgeBase', {}, ADD_WORKSPACE_FILES, [ + 'kb_1', + flags, + host, + ]) + } + + const CHUNK_FLAGS = { operation: 'disable', chunk: ['c1', 'c2'] } + + it('fails the process when no listed chunk matched', async () => { + request.mockResolvedValue({ + data: { + operation: 'disable', + processed: 0, + errors: ['No matching chunks found to disable: c1, c2'], + }, + }) + + await expect(updateChunks(CHUNK_FLAGS)).rejects.toThrow( + /No matching chunks found to disable: c1, c2/ + ) + }) + + it('succeeds on a partial chunk update', async () => { + request.mockResolvedValue({ + data: { operation: 'disable', processed: 1, errors: ['No matching chunks found: c2'] }, + }) + + await expect(updateChunks(CHUNK_FLAGS)).resolves.toBeUndefined() + }) + + it('fails the process when every file failed to index', async () => { + request.mockResolvedValue({ + data: { knowledgeBaseId: 'kb_1', added: [], failed: ['wf_1', 'wf_2'] }, + }) + + await expect(indexFiles({ file: ['wf_1', 'wf_2'] })).rejects.toThrow( + /Indexed nothing: none of the 2 requested files were added\./ + ) + }) + + it('succeeds on a partial index', async () => { + request.mockResolvedValue({ + data: { knowledgeBaseId: 'kb_1', added: [{ documentId: 'd_1' }], failed: ['wf_2'] }, + }) + + await expect(indexFiles({ file: ['wf_1', 'wf_2'] })).resolves.toBeUndefined() + }) + + /** Nothing asked for is nothing missed — an empty answer is still an answer. */ + it('succeeds when nothing was asked for', async () => { + request.mockResolvedValue({ data: { knowledgeBaseId: 'kb_1', added: [], failed: [] } }) + await expect(indexFiles({ file: ['wf_1'] })).resolves.toBeUndefined() + + request.mockResolvedValue({ data: { operation: 'disable', processed: 0, errors: [] } }) + await expect(updateChunks({ operation: 'disable', chunk: [] })).resolves.toBeUndefined() + }) +}) + /** * Response shapes that report a bulk outcome in the payload rather than in the * status code, and are deliberately left unchecked. @@ -379,10 +471,17 @@ describe('the bulk-outcome check covers every operation shaped like one', () => const shaped = Object.keys(V2_OPERATIONS).filter((operation) => { const declared = responseTypeSource(source, operation) if (/^\s+deletedItems\s*:/m.test(declared)) return true + // Two more spellings of the same shape, both of which the first pass of + // this detector missed: `added`/`failed` (indexing workspace files) and + // `processed`/`errors` (a bulk chunk update). + if (/^\s+processed\s*:/m.test(declared) && /^\s+errors\s*:/m.test(declared)) return true + if (/^\s+added\s*:/m.test(declared) && /^\s+failed\s*:/m.test(declared)) return true return /^\s+moved\s*:/m.test(declared) && /^\s+failed\s*:/m.test(declared) }) - expect(shaped.length).toBeGreaterThan(0) + expect(shaped).toEqual( + expect.arrayContaining(['addWorkspaceFilesToKnowledgeBase', 'bulkUpdateKnowledgeChunks']) + ) for (const operation of shaped) { if (UNCHECKED_BULK_OUTCOMES.has(operation)) continue expect(Object.keys(BULK_OUTCOME_CHECKS)).toContain(operation) diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 04da0da62d6..d56bda32502 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -9,11 +9,12 @@ import { DEFAULT_LIMIT } from './options' import { warnRenamedFlag } from './renamed' import { buildRequest, + cursorSlot, flagNameFor, isProfileWorkspacePath, PROFILE_INJECTED_FIELD, } from './request' -import { renderPage, renderResult } from './result' +import { foldPageEnvelope, renderPage, renderResult } from './result' import type { OperationSpec } from './types' /** @@ -93,6 +94,17 @@ export const BULK_OUTCOME_CHECKS: Readonly { + if (lengthOf(payload.added) > 0) return null + const failed = lengthOf(payload.failed) + if (failed === 0) return null + return `Indexed nothing: none of the ${failed} requested ${failed === 1 ? 'file was' : 'files were'} added.` + }, bulkDeleteTables: (payload) => { const items = payload.deletedItems as { tables?: unknown; folders?: unknown } | undefined const deleted = countOf(items?.tables) + countOf(items?.folders) @@ -101,6 +113,22 @@ export const BULK_OUTCOME_CHECKS: Readonly { + if (countOf(payload.processed) > 0) return null + const requested = lengthOf(body?.chunkIds) + if (requested === 0) return null + const reported = (payload.errors as unknown[] | undefined)?.[0] + return typeof reported === 'string' && reported + ? safeOneLine(reported) + : `Updated nothing: none of the ${requested} requested ${requested === 1 ? 'chunk' : 'chunks'} matched.` + }, moveTables: (payload) => { if (lengthOf(payload.moved) > 0) return null const missed = lengthOf(payload.notFound) + lengthOf(payload.failed) @@ -137,10 +165,34 @@ function bulkFailureMessage( return check(payload as Record, body) } -function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { - if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' - if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' - return null +/** + * Fields that cap a filtered mutation, beside the id list that supersedes them. + * + * `tables rows batch-delete --row a --row b --limit 1` deleted both rows: the + * route drops `limit` outright on the ids branch, so the cap was accepted, + * ignored, and never mentioned again. The refusal is client-side because that + * is where it costs nothing — every already-installed CLI sends `limit: 100` + * alongside `--row`, so a server that started rejecting the pair would break + * them all. + */ +const EXCLUSIVE_CAP_FIELDS: Readonly< + Partial> +> = { + deleteTableRows: { cap: 'limit', ids: 'rowIds' }, +} + +/** Refuses a row cap typed alongside the explicit id list that supersedes it. */ +function assertCapIsUsable(operation: V2OperationName, flags: Record): void { + const exclusive = EXCLUSIVE_CAP_FIELDS[operation] + if (!exclusive) return + + const cap = flagNameFor(operation, exclusive.cap) + const ids = flagNameFor(operation, exclusive.ids) + if (flags[camel(cap)] === undefined || flags[camel(ids)] === undefined) return + throw new SimApiError( + `--${cap} caps a --filter match and does nothing to an explicit --${ids} list; pass one, not both`, + 0 + ) } /** @@ -204,6 +256,7 @@ export async function executeOperation( } foldRenamedFlags(operation, commandSpec, requestFlags) + assertCapIsUsable(operation, requestFlags) /** * A dry run writes nothing, so it never needs the destructive confirmation. @@ -229,18 +282,43 @@ export async function executeOperation( (operationSpec.body && PROFILE_INJECTED_FIELD in operationSpec.body) ) const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true + /** + * A workspace carried in the path is resolved exactly like one carried in a + * field. `workspaces get` and `workspaces members` take theirs as a path + * parameter, so they skipped `requireWorkspace` and fell into `buildRequest`'s + * own fallback: a second wording for the same precondition, and — because + * `requireWorkspace` checks the key first — advice to set a workspace on an + * install whose actual first step is logging in. + */ + const needsWorkspace = + (hasWorkspaceField || commandSpec.profileWorkspacePath === true) && !omitsWorkspace const request = buildRequest( operation, positional, requestFlags, - hasWorkspaceField && !omitsWorkspace ? client.requireWorkspace() : profile.workspaceId + needsWorkspace ? client.requireWorkspace() : profile.workspaceId ) const paging = cursorSlot(operationSpec) if (paging) { - const rawLimit = Number.parseInt(String(requestFlags.limit ?? DEFAULT_LIMIT), 10) - if (Number.isNaN(rawLimit) || rawLimit < 0) { - throw new SimApiError('--limit must be a non-negative number', 0) + /** + * Read whole, not up to the first character that stops looking numeric. + * + * `parseInt` truncated before the guard could see what was typed, so + * `--limit 3.9` quietly fetched 3, `--limit 1e3` fetched 1, and + * `--limit -0.5` parsed as `-0` — which is not less than zero, so it slipped + * the guard and then read as the `0` that means everything. `Number` keeps + * the value intact so each of those is refused instead of reinterpreted, + * and it reads `0x10` and `1e3` as the caller wrote them. + * + * The empty string is refused explicitly because `Number('')` is `0`, and + * `0` here means "no ceiling": without this, `--limit ''` would go from + * today's error to an unbounded walk of a shared workspace. + */ + const limitText = String(requestFlags.limit ?? DEFAULT_LIMIT).trim() + const rawLimit = limitText === '' ? Number.NaN : Number(limitText) + if (!Number.isInteger(rawLimit) || rawLimit < 0) { + throw new SimApiError('--limit must be a whole number of 0 or more (0 for everything)', 0) } const limit = rawLimit === 0 ? Number.POSITIVE_INFINITY : rawLimit @@ -265,7 +343,7 @@ export async function executeOperation( ? { ...(request.body ?? {}), ...pageLimit, ...(cursor ? { cursor } : {}) } : request.body, }) - envelope ??= page + envelope = foldPageEnvelope(envelope, page) rows.push(...page.data) cursor = page.nextCursor if (cursor && rows.length < limit) progress.advance(rows.length) @@ -273,11 +351,15 @@ export async function executeOperation( } finally { progress.finish() } + // A cursor still in hand means the walk stopped at `--limit`, not at the + // end of the list — the one fact that separates a clipped answer from a + // complete one, and it was dropped with the loop variable. renderPage( profile.output, Number.isFinite(limit) ? rows.slice(0, limit) : rows, commandSpec, - envelope + envelope, + { truncated: Boolean(cursor) } ) return } @@ -289,9 +371,16 @@ export async function executeOperation( body: request.body, }) const payload = result?.data ?? result - renderResult(operation, profile.output, payload, commandSpec, { - expandedTrace: requestFlags.trace === true, - }) + renderResult( + operation, + profile.output, + payload, + commandSpec, + { expandedTrace: requestFlags.trace === true }, + // The envelope, not just the payload: a list that does not paginate states + // its own truncation there, and unwrapping `data` discarded it. + result + ) // Printed first, then failed, for the reason `followRun` gives: the envelope // carries the block outputs that explain *why* the run failed, and exiting diff --git a/packages/sim-cli/src/runtime/naming.test.ts b/packages/sim-cli/src/runtime/naming.test.ts new file mode 100644 index 00000000000..05ffb7ec988 --- /dev/null +++ b/packages/sim-cli/src/runtime/naming.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { CLI_CONTRACT } from '../contract/commands' +import type { CommandSpec } from '../contract/types' +import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' +import { formatApiErrorDetails, SimApiError } from '../http/client' +import { retypeApiError } from './naming' +import type { OperationSpec } from './types' + +function retype(operation: V2OperationName, error: unknown): SimApiError { + return retypeApiError( + error, + operation, + (CLI_CONTRACT[operation] ?? {}) as CommandSpec, + V2_OPERATIONS[operation] as unknown as OperationSpec + ) as SimApiError +} + +function detailLines(operation: V2OperationName, details: unknown): string[] { + return formatApiErrorDetails( + retype(operation, new SimApiError('Invalid request', 400, 'BAD_REQUEST', details)).details + ) +} + +/** + * The server names its own fields, which is right for an OpenAPI reader and + * untypeable in a terminal: `drop includeJobRuns` names no flag the CLI has. + */ +describe('a validation error restated in the spellings a caller can type', () => { + it('names the flag in both the message and the details', () => { + const message = + 'sortBy: only "startedAt" can order job runs; drop includeJobRuns or sort by "startedAt"' + const error = new SimApiError(message, 400, 'BAD_REQUEST', [{ path: ['sortBy'], message }]) + + const retyped = retype('listLogs', error) + expect(retyped.message).toContain('drop --include-job-runs') + expect(retyped.message).toContain('--sort-by') + expect(retyped.message).not.toMatch(/\bincludeJobRuns\b/) + expect(detailLines('listLogs', error)[1]).toContain('--sort-by') + }) + + /** + * The requirement that a mechanical kebab-casing fails: the flag for + * `folderPath` is `--folder`, so translating the wire name by rule would + * print a flag that does not exist. + */ + it('resolves the spelling through the contract, not by kebab-casing', () => { + const line = detailLines('listWorkflows', [ + { path: ['folderPath'], message: 'Path must be a canonical folder path' }, + ])[1] + + expect(line).toContain('--folder:') + expect(line).not.toContain('--folder-path') + expect(line).not.toContain('folderPath') + }) + + it('leaves an English word that happens to be a field name alone', () => { + const message = 'startDate must name a storable instant; there is no year 0000' + const retyped = retype( + 'listLogs', + new SimApiError(message, 400, 'BAD_REQUEST', [{ path: ['startDate'], message }]) + ) + + expect(retyped.message).toBe('--start-date must name a storable instant; there is no year 0000') + expect(retyped.message).not.toContain('--name') + }) + + it('names the global flag the workspace comes from', () => { + expect( + detailLines('listLogs', [{ path: ['workspaceId'], message: 'Workspace is required' }])[1] + ).toContain('--workspace:') + }) + + it('translates only the head of a path into a JSON value the caller wrote', () => { + const line = detailLines('queryRows', [ + { path: ['predicate', 'all', '0', 'op'], message: 'Unsupported operator' }, + ])[1] + + // `predicate` is typed `--filter`, so even the head is not a kebab-cased + // wire name — and only the head is translated. + expect(line).toContain('--filter.all.0.op:') + }) + + it('leaves a CLI-raised error and a non-API throw byte-identical', () => { + const local = new SimApiError('--limit must be a whole number of 0 or more', 0) + expect(retype('listLogs', local)).toBe(local) + + const other = new Error('sortBy is not a flag') + expect( + retypeApiError(other, 'listLogs', {}, V2_OPERATIONS.listLogs as unknown as OperationSpec) + ).toBe(other) + }) +}) diff --git a/packages/sim-cli/src/runtime/naming.ts b/packages/sim-cli/src/runtime/naming.ts new file mode 100644 index 00000000000..b0683c0e132 --- /dev/null +++ b/packages/sim-cli/src/runtime/naming.ts @@ -0,0 +1,140 @@ +import type { CommandSpec } from '../contract/types' +import type { V2OperationName } from '../generated/v2-api' +import { SimApiError } from '../http/client' +import { flagNameFor, flagSpecFor, PROFILE_INJECTED_FIELD, pathFlagNameFor } from './request' +import type { OperationSpec } from './types' + +/** + * Identifiers a message quotes from the wire rather than from English. + * + * Genuine multi-segment camelCase — `includeJobRuns`, `startDate` — never + * occurs as prose, so replacing it is safe. A single word is left alone in + * prose: `startDate must name a storable instant` contains the field `name`, + * and substituting it would produce `must --name a storable instant`. The + * structured `details:` column still translates single-word fields, because + * there position identifies them rather than the surrounding sentence. + */ +const WIRE_IDENTIFIER = /^[a-z]+[A-Z]/ + +/** + * The spelling a caller types for a wire field, or `null` when there is none. + * + * Resolved through the same helpers the command builder uses, never by + * kebab-casing the wire name: `folderPath` is typed `--folder` and + * `knowledgeBaseIds` is typed `--kb`, so a mechanical translation would name + * flags that do not exist — strictly worse than leaving the wire name alone. + */ +export function spellingFor( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec, + field: string +): string | null { + if (field === PROFILE_INJECTED_FIELD) return '--workspace' + if (field === 'cursor') return null + + if (operationSpec.pathParams.includes(field)) { + return commandSpec.pathFlags?.[field] + ? `--${pathFlagNameFor(commandSpec, field)}` + : `<${commandSpec.pathArgumentNames?.[field] ?? field}>` + } + + if (commandSpec.positionals?.includes(field)) return `<${flagNameFor(operation, field)}>` + if (flagSpecFor(operation, field).omit) return null + if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) return null + + const declared = + (operationSpec.query && field in operationSpec.query) || + (operationSpec.body && field in operationSpec.body) || + (operationSpec.headers && field in operationSpec.headers) + if (!declared) return null + + return `--${flagNameFor(operation, field)}` +} + +/** Every field of this operation a caller can type, keyed by its wire name. */ +function typeableFields( + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): Map { + const spellings = new Map() + const fields = [ + ...operationSpec.pathParams, + ...Object.keys(operationSpec.query ?? {}), + ...Object.keys(operationSpec.body ?? {}), + ...Object.keys(operationSpec.headers ?? {}), + ] + for (const field of fields) { + if (spellings.has(field)) continue + const spelling = spellingFor(operation, commandSpec, operationSpec, field) + if (spelling) spellings.set(field, spelling) + } + return spellings +} + +/** Rewrites wire names a message quotes into the flags the caller typed. */ +function retypeMessage(message: string, spellings: Map): string { + let retyped = message + for (const [field, spelling] of spellings) { + if (!WIRE_IDENTIFIER.test(field)) continue + retyped = retyped.replaceAll(new RegExp(`\\b${field}\\b`, 'g'), spelling) + } + return retyped +} + +/** Rewrites the head of one issue path, leaving keys inside a caller's JSON alone. */ +function retypeDetails(details: unknown, spellings: Map): unknown { + if (Array.isArray(details)) return details.map((issue) => retypeDetails(issue, spellings)) + if (!details || typeof details !== 'object') return details + + const issue = details as Record + const retyped: Record = { ...issue } + + if (Array.isArray(issue.path) && issue.path.length > 0) { + const [head, ...rest] = issue.path.map(String) + const spelling = spellings.get(head) + // Only the head names a field the caller typed; the rest address keys + // inside a JSON value they wrote themselves. + if (spelling) retyped.path = [spelling, ...rest] + } + if (typeof issue.message === 'string') { + retyped.message = retypeMessage(issue.message, spellings) + } + if (Array.isArray(issue.errors)) { + retyped.errors = retypeDetails(issue.errors, spellings) + } + + return retyped +} + +/** + * Restates a server validation error in the spellings the terminal accepts. + * + * The API names its own fields, correctly — `drop includeJobRuns` is right for + * an OpenAPI reader and untypeable here, where the flag is + * `--include-job-runs`. Applied at the one frame that still holds the + * operation, its command spec and its operation spec; by the time the error + * reaches the entrypoint that context is gone. + * + * A CLI-raised error (`status: 0`) is already phrased in flags and passes + * through untouched, as does anything that is not a `SimApiError`. + */ +export function retypeApiError( + error: unknown, + operation: V2OperationName, + commandSpec: CommandSpec, + operationSpec: OperationSpec +): unknown { + if (!(error instanceof SimApiError) || error.status === 0) return error + + const spellings = typeableFields(operation, commandSpec, operationSpec) + if (spellings.size === 0) return error + + return new SimApiError( + retypeMessage(error.message, spellings), + error.status, + error.code, + error.details === undefined ? undefined : retypeDetails(error.details, spellings) + ) +} diff --git a/packages/sim-cli/src/runtime/options.ts b/packages/sim-cli/src/runtime/options.ts index d1da7024017..7c87b2c9733 100644 --- a/packages/sim-cli/src/runtime/options.ts +++ b/packages/sim-cli/src/runtime/options.ts @@ -2,6 +2,7 @@ import { type Command, Option } from 'commander' import type { CommandSpec } from '../contract/types' import type { V2OperationName } from '../generated/v2-api' import { + cursorSlot, type FieldSpec, flagNameFor, flagSpecFor, @@ -69,12 +70,24 @@ function withoutWireVocabulary(documented: string): string { return documented.replace(WIRE_VOCABULARY_SENTENCE, ' ').trim() } +/** + * What `--limit` means on an operation that does not paginate. + * + * The pager's `0 for everything` spelling is false here: these routes bound the + * field at `1`, and the unbounded form is the flag left off entirely. Said in + * `--help` because nothing else in the terminal says it — the refusal only + * arrives from the server, after the caller has already typed the command. + */ +const NON_PAGINATED_LIMIT_HINT = + ' (caps a --filter match only; omit it to act on every match, and note 0 is not accepted)' + function addFieldOption( command: Command, operation: V2OperationName, field: string, descriptor: FieldSpec, - slot: 'query' | 'body' | 'headers' + slot: 'query' | 'body' | 'headers', + paginates: boolean ): void { if (field === PROFILE_INJECTED_FIELD || field === 'cursor') return @@ -84,7 +97,15 @@ function addFieldOption( const name = flagNameFor(operation, field) const short = flag.short ? `-${flag.short}, ` : '' - if (field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer')) { + // Gated on the operation actually paginating, not on the field's name: a + // `limit` on a non-paginating operation is a row cap the wire reads + // literally, and commander's `100` default rode into the body of every + // filtered `tables rows batch-delete` as a silent ceiling on what it deleted. + if ( + paginates && + field === 'limit' && + (descriptor.kind === 'number' || descriptor.kind === 'integer') + ) { command.option( '--limit ', 'Maximum items to return (0 for everything)', @@ -93,7 +114,11 @@ function addFieldOption( return } - const documented = describeField(flag, descriptor, name, field) + const documented = `${describeField(flag, descriptor, name, field)}${ + field === 'limit' && (descriptor.kind === 'number' || descriptor.kind === 'integer') + ? NON_PAGINATED_LIMIT_HINT + : '' + }` if (descriptor.kind === 'boolean' || flag.boolean) { const booleanDoc = withoutWireVocabulary(documented) @@ -134,7 +159,7 @@ function addFieldOption( const literalNull = slot === 'body' && !takesList && !wantsJson const describe = `${documented}${ takesList - ? ' (space-separated, or @path / @- with one value per line)' + ? ' (space-separated, or @path / @- with one value per line; @@value for a literal leading @)' : wantsJson ? ' (JSON, or @path / @- to read a file or stdin)' : '' @@ -183,11 +208,12 @@ export function addOperationOptions( ) } + const paginates = cursorSlot(operationSpec) !== null for (const slot of ['query', 'body', 'headers'] as const) { for (const [field, descriptor] of Object.entries(operationSpec[slot] ?? {})) { if (commandSpec.requestFields && !commandSpec.requestFields.includes(field)) continue if (commandSpec.positionals?.includes(field)) continue - addFieldOption(command, operation, field, descriptor, slot) + addFieldOption(command, operation, field, descriptor, slot, paginates) } } diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index f7a071f3a1d..24de9c926ff 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -114,6 +114,12 @@ describe('buildRequest', () => { }) }) + it('still sends an empty body string, which is how a description is cleared', () => { + expect(buildRequest('updateWorkflow', ['wf_1'], { description: '' }, WORKSPACE).body).toEqual({ + description: '', + }) + }) + describe('failures, all before any network call', () => { it('rejects a missing path arg', () => { expect(() => buildRequest('getTable', [], {}, WORKSPACE)).toThrow('Missing ') @@ -125,6 +131,20 @@ describe('buildRequest', () => { ) }) + /** + * The URL builder skips an empty value, so a blank filter was not sent and + * not refused either — `logs list --status ""` came back unfiltered while + * `--workflow ""` (a list flag) had always been an error. + */ + it('rejects an empty query filter the way it rejects an empty list entry', () => { + expect(() => buildRequest('listLogs', [], { status: '' }, WORKSPACE)).toThrow( + '--status cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { workflowName: '' }, WORKSPACE)).toThrow( + '--workflow-name cannot be empty' + ) + }) + it('rejects a missing required flag', () => { expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( '--data is required' @@ -241,6 +261,17 @@ describe('repeated flags encode per the field kind, not uniformly', () => { ) }) + /** + * `--allowed-emails @example.org` is the natural spelling of a domain + * pattern, and the `@` convention reads it as a file. The escape existed; the + * failure never mentioned it. + */ + it('points at @@ when an @ list value names no file', () => { + expect(() => + coerce(['@example.org'], { kind: 'array' }, { list: true }, 'allowed-emails') + ).toThrow(/cannot read example\.org.*write @@example\.org/s) + }) + it('rejects empty lines in a list file', () => { const path = join(tmpdir(), 'sim-cli-list-empty-line.txt') writeFileSync(path, 'file_1\n\nfile_2') diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index a542693b4c1..98226b57130 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -4,6 +4,7 @@ import type { CommandSpec, FlagSpec } from '../contract/types' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { type QueryValue, SimApiError } from '../http/client' import { camel, kebab } from './derive' +import type { OperationSpec } from './types' /** One request field, as the generator describes it. */ export interface FieldSpec { @@ -29,6 +30,21 @@ export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): return commandSpec.profileWorkspacePath === true && param === PROFILE_INJECTED_FIELD } +/** + * The slot a cursor-paginated operation carries its `cursor` field in. + * + * Pagination, not the name of a field, is what makes `limit` a page size. It + * lives here rather than beside its reader in `execute.ts` because `options.ts` + * has to ask the same question while it builds the flag, and importing + * `execute.ts` from `options.ts` would close a module cycle — `execute.ts` + * already reads `DEFAULT_LIMIT` from `options.ts`. + */ +export function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { + if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' + if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' + return null +} + /** Kinds the CLI can only accept as a JSON string. */ const JSON_KINDS = new Set(['object', 'array', 'unknown']) @@ -162,6 +178,21 @@ function readStdin(): string { * at all, because it can only be read as a request to open a file named * `urgent`. */ +/** + * Points at `@@` when an `@value` names nothing on disk. + * + * `--allowed-emails @example.org` is the natural spelling of a domain pattern + * and reads here as a request to open a file, and "cannot read example.org" + * alone gives no clue the value has a literal spelling at all. Gated on ENOENT + * so a real file that cannot be read (EACCES, EISDIR) is not answered with + * advice about escaping. + */ +function literalAtHint(error: unknown, path: string): string { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT' + ? `. To pass the literal value @${path}, write @@${path}` + : '' +} + export function readArgumentSource(raw: string, flagName: string): { text: string; from: string } { if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' } if (!raw.startsWith('@')) return { text: raw, from: '' } @@ -181,7 +212,10 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin try { return { text: readFileSync(path, 'utf8'), from: ` (read from ${path})` } } catch (error) { - throw new SimApiError(`--${flagName} cannot read ${path}: ${(error as Error).message}`, 0) + throw new SimApiError( + `--${flagName} cannot read ${path}: ${(error as Error).message}${literalAtHint(error, path)}`, + 0 + ) } } @@ -357,6 +391,16 @@ export function coerce(raw: unknown, field: FieldSpec, flag: FlagSpec, flagName: return raw } +/** + * The workspace precondition as stated when the profile is not at hand. + * + * `executeOperation` resolves the workspace through `SimClient.requireWorkspace` + * first, which names the profile and checks the API key, so this is a defensive + * floor rather than the wording a caller sees. + */ +const NO_WORKSPACE_FALLBACK = + 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' + export interface BuiltRequest { path: string query: Record @@ -417,10 +461,7 @@ export function buildRequest( : positional[positionalIndex++] if (value === undefined || value === null) { if (profileWorkspacePath) { - throw new SimApiError( - 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ', - 0 - ) + throw new SimApiError(NO_WORKSPACE_FALLBACK, 0) } throw new SimApiError(pathFlag ? `--${flagName} is required` : `Missing <${argumentName}>`, 0) } @@ -462,9 +503,7 @@ export function buildRequest( if (value === undefined) { if (descriptor.required) { throw new SimApiError( - field === PROFILE_INJECTED_FIELD - ? 'No workspace set. Pass --workspace, or run: sim configure --set-workspace ' - : `--${flagName} is required`, + field === PROFILE_INJECTED_FIELD ? NO_WORKSPACE_FALLBACK : `--${flagName} is required`, 0 ) } @@ -473,6 +512,19 @@ export function buildRequest( continue } + /** + * A blank filter is a mistake, and every v2 JSON route says so + * (`rejectBlankQueryValues`). The CLI never let one reach the wire: the + * URL builder skips an empty value, so `logs list --status ""` searched + * everything and answered `0`, a wider result set presented as an answer. + * Refused here, before the request, the way an empty list entry and an + * empty path parameter already are. Scoped to the query, because an empty + * body string is meaningful — it clears a description. + */ + if (slot === 'query' && value === '') { + throw new SimApiError(`--${flagName} cannot be empty`, 0) + } + if (slot === 'query') query[field] = asQueryValue(value) // A header is a wire string: the contracts declare only string headers, // and anything else would reach `fetch` as `[object Object]`. diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index 8a72471e1cc..46b5ce75fe2 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec } from '../contract/types' -import { renderPage, renderResult } from './result' +import { encodeFolderPath } from './request' +import { decodeFolderPath, renderPage, renderResult } from './result' let logged: string[] @@ -244,8 +245,66 @@ describe('folder paths are shown by name, but piped in wire form', () => { expect(logged).toContain(`web URL\t${webUrl}`) }) + /** + * `%2F` is the one escape that must survive display: decoding it prints a + * root folder named `a/enc` exactly like a folder `enc` nested under `a`, and + * the path people paste back then resolves to the other folder. + */ + describe('a folder whose own name contains the separator', () => { + const slashNamed = [ + { + path: '/cli-test-a%2Fenc', + name: 'cli-test-a/enc', + parentPath: '/', + updatedAt: '2026-08-17T20:35:38.478Z', + }, + ] + const nested = [ + { + path: '/cli-test-a/enc', + name: 'enc', + parentPath: '/cli-test-a', + updatedAt: '2026-08-17T20:35:38.478Z', + }, + ] + + it('keeps it distinguishable from a genuinely nested folder in the table', () => { + renderPage('table', slashNamed, spec) + const [, slashRow] = tableLines() + logged = [] + renderPage('table', nested, spec) + const [, nestedRow] = tableLines() + + expect(slashRow).toContain('%2F') + expect(slashRow.split(/\s{2,}/)[0]).not.toBe(nestedRow.split(/\s{2,}/)[0]) + }) + + it('prints the wire form in text, which is what a script pipes back', () => { + renderPage('text', slashNamed, spec) + expect(logged[0].split('\t')[0]).toBe('/cli-test-a%2Fenc') + }) + + it('survives a round trip back through the encoder', () => { + expect(encodeFolderPath(decodeFolderPath('/cli-test-a%2Fenc'))).toBe('/cli-test-a%2Fenc') + }) + }) + it('shows an undecodable path as it arrived rather than dropping it', () => { renderPage('text', [{ path: '/100%zz', name: 'x', parentPath: '/', updatedAt: null }], spec) expect(logged[0].split('\t')[0]).toBe('/100%zz') }) }) + +describe('a truncation note', () => { + /** The note is stderr in every format; stdout stays exactly the rows. */ + it('leaves the machine formats a bare array', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + + renderPage('json', [{ id: 'a' }], {}, { truncated: true }, { truncated: true }) + + expect(JSON.parse(logged.join('\n'))).toEqual([{ id: 'a' }]) + expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join('')).toContain( + 'more results exist' + ) + }) +}) diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 4b4bb4a400b..d8b41f3857b 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -47,6 +47,12 @@ function at(row: unknown, path: string): unknown { * decode is shown as it arrived rather than dropped — the point is to show the * name, and a malformed one is still the truth about what the server holds. * + * A segment whose decoded name contains the separator is shown in wire form for + * the same reason: decoding it would print a root folder named `a/b` as + * `/a/b`, byte-identical to a folder `b` nested under `a` — and the printed + * path is what people paste back, so `folders delete` addressed the other + * folder. Rendering must not manufacture structure that is not there. + * * Callers must reach this only from a `table` or `text` rendering path — the * hand-written `ls` builds its own columns and so decodes through here directly. * `json` and `yaml` render from the raw payload so that switching format never @@ -57,7 +63,8 @@ export function decodeFolderPath(value: string): string { .split('/') .map((segment) => { try { - return decodeURIComponent(segment) + const decoded = decodeURIComponent(segment) + return decoded.includes('/') ? segment : decoded } catch { return segment } @@ -278,9 +285,12 @@ export function renderPage( format: OutputFormat, rows: unknown[], spec: CommandSpec, - envelope?: unknown + envelope?: unknown, + options: { truncated?: boolean } = {} ): void { writePageNote(spec, envelope) + writeEnvelopeTruncation(envelope) + writeCursorTruncation(rows.length, options.truncated === true) printList( format, rows, @@ -304,14 +314,93 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { process.stderr.write(chalk.dim(`${spec.pageNote.label}: ${String(value)}\n`)) } +/** + * Envelope fields that state the server itself clipped the list. + * + * Matched by shape rather than listed per command, so a flag added to a route + * envelope is surfaced the day it lands: the CLI accumulates the rows and + * prints those, so an envelope field reaches no output format on its own. + */ +const TRUNCATION_FLAG = /^truncated$|Truncated$/ + +/** The envelope flags a page raised, in the spelling the wire used. */ +function truncationFlags(envelope: unknown): string[] { + if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return [] + return Object.entries(envelope) + .filter(([key, value]) => value === true && TRUNCATION_FLAG.test(key)) + .map(([key]) => key) +} + +/** + * Carries a truncation stated on any page, not only the first. + * + * The envelope is otherwise the first page's, because a fact about the whole + * query (billing's `scope`) is stated once — but `toolNamesTruncated` is + * computed per page, so a walk that clipped on page 7 would have said nothing. + */ +export function foldPageEnvelope(current: unknown, page: unknown): unknown { + if (current === undefined) return page + const raised = truncationFlags(page) + if (raised.length === 0 || !current || typeof current !== 'object') return current + return { + ...(current as Record), + ...Object.fromEntries(raised.map((flag) => [flag, true])), + } +} + +/** `toolNamesTruncated` as a reader says it. */ +function spellOut(flag: string): string { + return flag + .replace(/([a-z])([A-Z])/g, '$1 $2') + .toLowerCase() + .trim() +} + +/** + * States a server-side clip once, on stderr, in every format. + * + * The API answers a clipped inventory with a flag on the envelope, and the CLI + * printed only `data` — so `--output json` could not tell a complete list from + * one the server cut short. stdout stays byte-for-byte the rows, for the reason + * `writePageNote` gives. + */ +function writeEnvelopeTruncation(envelope: unknown): void { + for (const flag of truncationFlags(envelope)) { + process.stderr.write( + chalk.dim(`${spellOut(flag)}: the server clipped this list, so it is incomplete\n`) + ) + } +} + +/** + * States that the walk stopped at `--limit` while more pages remained. + * + * `sim tools list` answered 100 of 4708 rows with an exit code of 0 and nothing + * on stderr, in every format — indistinguishable from a complete inventory. + * Said whenever a cursor survives, including when the caller set a small limit: + * the answer is incomplete either way, and the caller who capped it is the one + * most likely to reuse the result as if it were whole. + */ +function writeCursorTruncation(count: number, truncated: boolean): void { + if (!truncated) return + process.stderr.write( + chalk.dim(`showing the first ${count}; more results exist — re-run with --limit 0 for all\n`) + ) +} + /** Renders one non-paginated operation result according to its CLI contract. */ export function renderResult( operation: V2OperationName, format: OutputFormat, raw: unknown, spec: CommandSpec, - options: RenderResultOptions = {} + options: RenderResultOptions = {}, + envelope?: unknown ): void { + // Before any branch: the flag lives on the envelope `execute` unwraps one + // line before rendering, and states a fact about the payload below it. + writeEnvelopeTruncation(envelope) + if (spec.document) { printDocument(format, raw) return diff --git a/packages/sim-cli/src/runtime/types.ts b/packages/sim-cli/src/runtime/types.ts index f08294e3e23..9bd155d1584 100644 --- a/packages/sim-cli/src/runtime/types.ts +++ b/packages/sim-cli/src/runtime/types.ts @@ -13,5 +13,12 @@ export interface OperationSpec { headers?: Record opaqueBody?: boolean summary?: string + /** + * The operation rejects a workspace API key; only a personal one works. + * + * Emitted by `scripts/generate-v2-cli-api.ts` from the OpenAPI description so + * `--help` states the restriction the caller would otherwise meet as a `403`. + */ + personalKeyOnly?: true responseMode?: 'json' | 'binary' | 'stream' } From 6a4b33b9d8c3ca779b83508d1d17977528abfd69 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:28:23 -0700 Subject: [PATCH 10/15] fix(cli): gate destructive table imports and fix follow-mode rendering A `tables import --mode replace` empties the table before its first batch, so the only warning was in the describe. It now confirms, and the wording tells the truth per mode: cancelling a replace leaves a prefix of the new file with the originals already gone, while an append re-adds its rows if the file is imported twice. `--yes` skips the gate, and the gate runs before the file is opened. Import and export cancellation carried no describe at all; the import one now confirms, the export one records why it deliberately does not. Follow-mode output truncated cells to whatever the first row happened to measure, so a longer status or workflow name arrived clipped with no signal. Cells now clamp at a shared ceiling and pad to the lock, and the log columns carry width floors so a short first page cannot pin a column narrower than its own values. Interrupting a staged download left the staging directory behind; it is now removed on SIGINT and SIGTERM before the signal is re-raised. `--select-output` without `--follow` selected from a response that does not carry outputs, and said nothing. It is refused client-side, with a separate message for `--async`. Its describe now names what the path addresses. `secrets set` always read a value, even when only metadata flags were passed. Off a TTY that was an immediate refusal, so a metadata-only edit exited 1 in CI for a value it was never asked for; on a TTY it stopped to prompt, and the prompt rejects an empty entry, so there was no way to say "leave the stored value alone" short of re-typing the secret. The read is now skipped and the field omitted, which is what lets a metadata-only edit run unattended. On a TTY, setting only a description no longer prompts. Passing both spellings of the reveal flag is refused rather than silently resolved. Four mandatory hand-authored flags now say so, `billing logs` names its key-type scope, and the dispatch list declares its columns. --- .../sim-cli/src/commands/credentials.test.ts | 18 ++ packages/sim-cli/src/commands/credentials.ts | 10 +- .../src/commands/protocol/files-get.test.ts | 64 ++++++- .../src/commands/protocol/files-get.ts | 61 ++++++- .../src/commands/protocol/logs-follow.test.ts | 87 ++++++++- .../src/commands/protocol/logs-follow.ts | 32 +++- .../commands/protocol/tables-import.test.ts | 42 +++++ .../src/commands/protocol/tables-import.ts | 20 +++ .../protocol/workflow-run-follow.test.ts | 40 +++++ .../commands/protocol/workflow-run-follow.ts | 13 ++ packages/sim-cli/src/commands/secrets.test.ts | 44 +++++ packages/sim-cli/src/commands/secrets.ts | 43 ++++- .../sim-cli/src/contract/commands.test.ts | 166 +++++++++++++++++- packages/sim-cli/src/contract/commands.ts | 97 ++++++++-- packages/sim-cli/src/contract/types.ts | 19 +- packages/sim-cli/src/generated/v2-api.ts | 89 ++++++++-- packages/sim-cli/src/program.test.ts | 47 +++++ 17 files changed, 843 insertions(+), 49 deletions(-) diff --git a/packages/sim-cli/src/commands/credentials.test.ts b/packages/sim-cli/src/commands/credentials.test.ts index ab0e66f88a7..e4071096593 100644 --- a/packages/sim-cli/src/commands/credentials.test.ts +++ b/packages/sim-cli/src/commands/credentials.test.ts @@ -156,6 +156,24 @@ describe('credential connection commands', () => { expect(help).not.toContain('--service-account-json') }) + it('marks its mandatory flags required in the help it renders', () => { + // Commander enforces `requiredOption` but renders nothing to say so — the + // marker is literal text the generated flags carry, so a hand-written + // command is the one place a required flag can look optional. + const flat = (...names: string[]) => + commandAt(...names) + .helpInformation() + .replace(/\s+/g, ' ') + + expect(flat('credentials', 'create')).toContain( + 'Name shown for the credential in Sim (required)' + ) + expect(flat('credentials', 'create')).toContain('read a file or stdin) (required)') + expect(flat('credentials', 'connect')).toContain( + 'Name shown for the new credential in Sim (required)' + ) + }) + it('rejects missing and unsupported provider fields before creation', async () => { mockRequest.mockReset().mockResolvedValue({ data: [ diff --git a/packages/sim-cli/src/commands/credentials.ts b/packages/sim-cli/src/commands/credentials.ts index b90e7167a3b..e95c20dea25 100644 --- a/packages/sim-cli/src/commands/credentials.ts +++ b/packages/sim-cli/src/commands/credentials.ts @@ -193,10 +193,14 @@ export function attachCredentialCommands(program: Command): void { .command('create') .argument('', 'Service-account provider to create a credential for') .description('Create a service-account credential using its discovered provider schema') - .requiredOption('--name ', 'Name shown for the credential in Sim') + // The `(required)` suffix is the marker the generated flags carry, and it + // is literal text rather than something commander renders — a hand-written + // mandatory option that omits it is the only kind of required flag whose + // help does not say so. + .requiredOption('--name ', 'Name shown for the credential in Sim (required)') .requiredOption( '--credentials ', - 'Provider credentials as JSON (or @path / @- to read a file or stdin)' + 'Provider credentials as JSON (or @path / @- to read a file or stdin) (required)' ) .option('--description ', 'Optional credential description') .option( @@ -211,7 +215,7 @@ export function attachCredentialCommands(program: Command): void { .command('connect') .argument('', 'OAuth provider to connect') .description('Create a short-lived link for connecting an OAuth provider') - .requiredOption('--name ', 'Name shown for the new credential in Sim') + .requiredOption('--name ', 'Name shown for the new credential in Sim (required)') .action(async (providerId: string, options: { name: string }, command: Command) => createConnectionLink(command, { providerId, displayName: options.name }) ) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 2cf7599bb9c..2fec95b8576 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -3,6 +3,7 @@ import { existsSync, lstatSync, mkdtempSync, + readdirSync, readFileSync, rmSync, symlinkSync, @@ -14,7 +15,12 @@ import { Writable } from 'node:stream' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build' -import { isTerminalSafeContentType, saveToFile, streamToFile } from './files-get' +import { + isTerminalSafeContentType, + removeStagingOnSignal, + saveToFile, + streamToFile, +} from './files-get' import { attachProtocolCommands } from './index' const { output, requestRaw } = vi.hoisted(() => ({ @@ -89,6 +95,62 @@ function program(): Command { return root } +describe('an interrupted download', () => { + /** Staging directories left beside a destination, as `ls -a` shows them. */ + function stagingDirectories(): string[] { + return readdirSync(dir).filter((entry) => entry.startsWith('.sim-download-')) + } + + it('removes the staging directory and re-raises when a signal arrives', () => { + const staging = mkdtempSync(join(dir, '.sim-download-')) + writeFileSync(join(staging, 'payload'), 'partial') + // Injected: the real termination re-raises the signal, which would take the + // test runner down with it. + const terminate = vi.fn() + const dispose = removeStagingOnSignal(() => staging, terminate) + + process.emit('SIGINT') + dispose() + + expect(existsSync(staging)).toBe(false) + expect(terminate).toHaveBeenCalledWith('SIGINT') + }) + + it('watches for signals only while a download is staged', async () => { + const before = { int: process.listenerCount('SIGINT'), term: process.listenerCount('SIGTERM') } + let observed = 0 + const body = new ReadableStream({ + pull(controller) { + observed = process.listenerCount('SIGINT') + controller.enqueue(new TextEncoder().encode('data')) + controller.close() + }, + }) + + await saveToFile(body, join(dir, 'out.bin'), false) + + expect(observed).toBe(before.int + 1) + expect(process.listenerCount('SIGINT')).toBe(before.int) + expect(process.listenerCount('SIGTERM')).toBe(before.term) + }) + + it('disposes the watch when the publish itself fails', async () => { + const target = join(dir, 'out.txt') + writeFileSync(target, 'precious') + const before = process.listenerCount('SIGINT') + + await expect(saveToFile(bodyOf(['new']), target, false)).rejects.toThrow(/already exists/) + + expect(process.listenerCount('SIGINT')).toBe(before) + }) + + it('leaves no staging directory behind on a completed download', async () => { + await saveToFile(bodyOf(['done']), join(dir, 'out.bin'), false) + + expect(stagingDirectories()).toEqual([]) + }) +}) + describe('streamToFile', () => { it('writes the body to disk', async () => { const target = join(dir, 'out.txt') diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 31c4c84ed26..83ba08f9e47 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -1,5 +1,5 @@ import { once } from 'node:events' -import { createWriteStream, type WriteStream } from 'node:fs' +import { createWriteStream, rmSync, type WriteStream } from 'node:fs' import { link, lstat, mkdtemp, readlink, rename, rm } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { Readable, type Writable } from 'node:stream' @@ -89,6 +89,60 @@ export async function streamToFile( } } +/** Signals that end the process while a download is staged beside its target. */ +const STAGE_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] + +/** + * Ends the process by the signal that arrived, once our own handler has run. + * + * Installing a listener suppresses Node's default termination, so the handler + * has to terminate itself. Re-raising rather than `process.exit(130)` keeps the + * process dying *by signal*, so a wrapping shell still sees 130/143 and a + * `trap` still fires — the behaviour an interrupted download has today. + */ +function reRaise(signal: NodeJS.Signals): void { + process.removeAllListeners(signal) + process.kill(process.pid, signal) +} + +/** + * Removes the staging directory when a signal ends the process. + * + * `saveStagedFile` cleans up in normal control flow, which a signal never + * reaches: the process is torn down mid-`pipeline`, so every Ctrl-C left + * another `.sim-download-*` holding a partial payload beside the destination. + * The removal is synchronous because the termination that follows gives an + * async `rm` no turn to run. + * + * Exported for its own test: driving it through a real interrupt would take the + * test runner down with it. + */ +export function removeStagingOnSignal( + stagingDirectory: () => string | null, + terminate: (signal: NodeJS.Signals) => void = reRaise +): () => void { + const installed = STAGE_SIGNALS.map((signal) => { + const onSignal = () => { + const directory = stagingDirectory() + if (directory) { + try { + rmSync(directory, { recursive: true, force: true }) + } catch { + // A staging directory we cannot remove is not worth masking the + // interrupt the caller asked for. + } + } + terminate(signal) + } + process.on(signal, onSignal) + return [signal, onSignal] as const + }) + + return () => { + for (const [signal, onSignal] of installed) process.off(signal, onSignal) + } +} + async function saveStagedFile( body: ReadableStream, target: string, @@ -96,6 +150,7 @@ async function saveStagedFile( ): Promise { let temporaryDirectory: string | null = null let failure: SimApiError | null = null + const disposeSignalCleanup = removeStagingOnSignal(() => temporaryDirectory) try { const publicationTarget = force ? await forcedPublicationTarget(target) : target @@ -113,6 +168,10 @@ async function saveStagedFile( } } catch (error) { failure = normalizedWriteFailure(target, error) + } finally { + // Disposed on every path out, including the publish failure below: a + // handler left installed would outlive the directory it removes. + disposeSignalCleanup() } if (temporaryDirectory) { diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts index 91da8871dfa..871fef9373d 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts @@ -3,9 +3,10 @@ */ import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CLI_CONTRACT } from '../../contract/commands' import { type ListLogsResponse, V2_OPERATIONS } from '../../generated/v2-api' import { SimApiError } from '../../http/client' -import { attachLogsFollow, type LogRow } from './logs-follow' +import { attachLogsFollow, type LogRow, MAX_CELL_WIDTH } from './logs-follow' const { mockRequest, mockSleep, profile } = vi.hoisted(() => ({ mockRequest: vi.fn(), @@ -236,6 +237,90 @@ describe('sim logs follow', () => { expect(stdout).toHaveLength(3) }) + it('keeps a run id whole when the follow started with an empty backlog', async () => { + // `-n 0` seeds the writer with no rows, so the widths used to lock to the + // header labels — RUN is three characters, and a 36-character run id + // printed as `9f…`, uncopyable. + profile.output = 'table' + const runId = '9f5e9856-1801-4028-a85f-6e335e65d974' + const arrival = row(runId, '2026-08-17T10:00:01.000Z') + arrival.workflow = { + id: 'wf_1', + name: 'clitest-nightly-sync', + description: null, + deleted: false, + } + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const printed = stdout.join('\n') + expect(printed).toContain(runId) + expect(printed).toContain('clitest-nightly-sync') + expect(printed).not.toContain('…') + }) + + it('lines an arriving row up with the header it printed before any rows', async () => { + // The floors are the half of this that keeps `-n 0` readable: without them + // the columns lock to their header labels, and every cell of the first real + // row overflows, so nothing below the header lines up for the life of the + // follow. + profile.output = 'table' + const arrival = row('9f5e9856-1801-4028-a85f-6e335e65d974', '2026-08-17T10:00:01.000Z') + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const [header, printed] = stdout + expect(printed.indexOf('completed')).toBe(header.indexOf('STATUS')) + expect(printed.indexOf('Nightly sync')).toBe(header.indexOf('WORKFLOW')) + expect(printed.indexOf('9f5e9856')).toBe(header.indexOf('RUN')) + }) + + it('keeps a later row wider than the batch that locked the widths', async () => { + profile.output = 'table' + const first = row('run_1', '2026-08-17T10:00:01.000Z') + first.workflow = { id: 'wf_1', name: 'short', description: null, deleted: false } + const second = row('run_2', '2026-08-17T10:00:02.000Z') + second.workflow = { + id: 'wf_2', + name: 'a-considerably-longer-workflow-name', + description: null, + deleted: false, + } + respondWith([page([first]), page([second, first])]) + + await follow('-n', '1') + + expect(stdout.join('\n')).toContain('a-considerably-longer-workflow-name') + }) + + it('still cuts a cell at the width one column may ever take', async () => { + profile.output = 'table' + const huge = 'x'.repeat(MAX_CELL_WIDTH + 40) + const arrival = row('run_1', '2026-08-17T10:00:01.000Z') + arrival.workflow = { id: 'wf_1', name: huge, description: null, deleted: false } + respondWith([page([]), page([arrival])]) + + await follow('-n', '0') + + const printed = stdout.join('\n') + expect(printed).not.toContain(huge) + expect(printed).toContain(`${'x'.repeat(MAX_CELL_WIDTH - 1)}…`) + }) + + it('asks for no column floor wider than a column may render', () => { + // A floor above the cap is silently clamped, so an oversized spec would + // read as deliberate and do nothing. + const oversized = Object.entries(CLI_CONTRACT).flatMap(([operation, spec]) => + [...(spec.columns ?? []), ...(spec.fields ?? [])] + .filter((column) => (column.minWidth ?? 0) > MAX_CELL_WIDTH) + .map((column) => `${operation}.${column.header}`) + ) + + expect(oversized).toEqual([]) + }) + it('keeps rows on stdout and retry notices on stderr', async () => { Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }) const first = row('run_1', '2026-08-17T10:00:01.000Z') diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.ts b/packages/sim-cli/src/commands/protocol/logs-follow.ts index 3f9d4269941..330cc86011b 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.ts @@ -62,7 +62,7 @@ const MAX_PAGES_PER_POLL = 10 const MAX_REMEMBERED_RUNS = 5000 /** Widest a table column renders, matching the `logs list` table. */ -const MAX_CELL_WIDTH = 60 +export const MAX_CELL_WIDTH = 60 /** How often an interruptible wait checks whether Ctrl-C has arrived. */ const WAIT_SLICE_MS = 250 @@ -135,8 +135,14 @@ function renderCell(value: unknown, format: ColumnSpec['format']): string { } } -const COLUMNS: Column[] = (CLI_CONTRACT.listLogs?.columns ?? []).map((spec) => ({ +interface FollowColumn extends Column { + /** Narrowest this column may lock to, from the contract's `minWidth`. */ + floor: number +} + +const COLUMNS: FollowColumn[] = (CLI_CONTRACT.listLogs?.columns ?? []).map((spec) => ({ header: spec.header, + floor: Math.min(MAX_CELL_WIDTH, spec.minWidth ?? 0), value: (row) => renderCell(at(row, spec.path ?? spec.header), spec.format), })) @@ -156,7 +162,7 @@ function pad(value: string, width: number): string { } /** - * Truncates a cell to its locked column width. + * Truncates a cell to the widest a column may ever render. * * Skipped unless the visible width equals the string length, which is only true * of text carrying neither an escape sequence nor a wide character — slicing @@ -178,18 +184,32 @@ type RowWriter = (rows: LogRow[]) => void * table. Locking the widths is what `kubectl get -w` does, for the same reason. * The header prints on the first call even when that call carries no rows, so * the columns are labelled from the start rather than from the first run. + * + * The lock decides the layout, never the content: a cell wider than its column + * overflows and pushes the rest of its row right, because clamping to a width + * the first batch happened to set is how a copyable run id became `9f…`. Cells + * are still cut at {@link MAX_CELL_WIDTH}, the cap `logs list` uses, so one + * LLM-sized cell cannot take the view. The contract's per-column floors are + * what keep the ragged case rare — without them `-n 0` locks every column to + * its header label and the whole follow prints askew. */ function createTableWriter(): RowWriter { let widths: number[] | null = null return (rows) => { - const lines = rows.map((row) => COLUMNS.map((column) => oneLine(column.value(row)))) + const lines = rows.map((row) => + COLUMNS.map((column) => clamp(oneLine(column.value(row)), MAX_CELL_WIDTH)) + ) if (!widths) { widths = COLUMNS.map((column, index) => Math.min( MAX_CELL_WIDTH, - Math.max(visibleWidth(column.header), ...lines.map((line) => visibleWidth(line[index]))) + Math.max( + column.floor, + visibleWidth(column.header), + ...lines.map((line) => visibleWidth(line[index])) + ) ) ) const header = widths @@ -206,7 +226,7 @@ function createTableWriter(): RowWriter { for (const line of lines) { console.log( line - .map((cell, index) => pad(clamp(cell, locked[index]), locked[index])) + .map((cell, index) => pad(cell, locked[index])) .join(' ') .trimEnd() ) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.test.ts b/packages/sim-cli/src/commands/protocol/tables-import.test.ts index 1b749460de2..9747b6fd2c7 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.test.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.test.ts @@ -77,6 +77,48 @@ describe('tables import argument guards', () => { await expect(runImport(['f.csv', '--mode', 'append'])).rejects.toThrow(/applies to --table-id/) }) + it('refuses to replace an existing table without --yes', async () => { + await expect(runImport(['f.csv', '--table-id', 't', '--mode', 'replace'])).rejects.toThrow( + /Re-run with --yes to confirm/ + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('replaces an existing table once --yes is passed', async () => { + mockRequest.mockResolvedValue({ + data: { + session: { id: 'i1', status: 'completed', tableId: 't', rowsProcessed: 0, error: null }, + uploadToken: null, + transfer: null, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runImport(['--file-id', 'w_1', '--table-id', 't', '--mode', 'replace', '--yes']) + + expect(mockRequest).toHaveBeenCalled() + const body = (mockRequest.mock.calls[0][1] as { body: Record }).body + expect(body.target).toEqual({ type: 'existing', tableId: 't', mode: 'replace' }) + }) + + it('leaves the shapes that write nothing away ungated', async () => { + mockRequest.mockResolvedValue({ + data: { + session: { id: 'i1', status: 'completed', tableId: 't', rowsProcessed: 0, error: null }, + uploadToken: null, + transfer: null, + }, + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await runImport(['--file-id', 'w_1', '--table-id', 't', '--mode', 'append']) + expect(mockRequest).toHaveBeenCalled() + + mockRequest.mockClear() + await runImport(['--file-id', 'w_1', '--name', 'Customers']) + expect(mockRequest).toHaveBeenCalled() + }) + it('rejects an invalid import mode before making a request', async () => { await expect( runImport(['--file-id', 'w_1', '--name', 'Customers', '--mode', 'merge']) diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index f746c964934..62f12c7b05a 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -26,6 +26,7 @@ interface ImportOptions { createColumns?: string timezone?: string wait: boolean + yes?: boolean } const IMPORT_POLL_MS = 1500 @@ -163,6 +164,10 @@ export function attachTableImport(tables: Command): void { .option('--mapping ', 'Column mapping (--table-id only)') .option('--create-columns ', 'Columns to create (--table-id only)') .option('--timezone ', 'Timezone for date parsing, e.g. America/New_York') + // Not the bare `(required)` marker the generated flags use: the docs + // generator keys its Required column off that exact suffix, and this one is + // required for a single shape of the command. + .option('-y, --yes', 'Confirm this destructive operation (required with --mode replace)') .option('--no-wait', 'Return once the import is queued instead of watching it') .action(async (path: string | undefined, options: ImportOptions, command: Command) => { const { client, profile } = clientFrom(command) @@ -173,6 +178,21 @@ export function attachTableImport(tables: Command): void { } const intoExisting = validateTargetOptions(options) + + // Gated the way the eleven destructive `tables` leaves are, but only for + // the shape that destroys something: `--mode replace` empties the table + // before its first batch, while an append or a new table writes nothing + // away. Refused before the file is opened, as the target-option guards + // above are, so the refusal costs nothing. Widening it to every import + // would put a prompt on the common path and teach the reflexive `--yes` + // the gate depends on nobody learning. + if (intoExisting && options.mode === 'replace' && options.yes !== true) { + throw new SimApiError( + 'This deletes every row in the table before loading the CSV and cannot be undone. Re-run with --yes to confirm.', + 0 + ) + } + const local = path ? await localFile(path) : null const source = local ? { diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index 7fe1dbb3950..fd5cf864c7d 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -274,6 +274,46 @@ describe('sim workflows run --follow', () => { expect(request).not.toHaveBeenCalled() }) + it('refuses --select-output without --follow and sends nothing', async () => { + await expect(run('wf_1', '--select-output', 'agent_1.content')).rejects.toThrow(/add --follow/) + expect(request).not.toHaveBeenCalled() + expect(requestRaw).not.toHaveBeenCalled() + }) + + it('points a refused --select-output at the dialect the run resource takes', async () => { + // The caller just typed a block *name*, which is what this flag accepts and + // what `workflows runs get` rejects, so a hint that only repeated the flag + // would send them into a second 400. + await expect(run('wf_1', '--select-output', 'agent_1.content')).rejects.toThrow( + /workflows runs get .*--select-output \[\.path\].*block ids, not the block names/s + ) + }) + + it('tells --async --select-output that no stream is coming, rather than to follow', async () => { + // `--async --follow` is refused outright, so "add --follow" would be advice + // that cannot be taken. + const failure = await run('wf_1', '--async', '--select-output', 'agent_1.content').catch( + (error: Error) => error + ) + + expect(failure?.message).toContain('--async returns as soon as the run is queued') + expect(failure?.message).not.toContain('add --follow') + expect(request).not.toHaveBeenCalled() + }) + + it('sends the selection with the stream once --follow is passed', async () => { + requestRaw.mockResolvedValue(streamResponse(sse({ event: 'final', data: { success: true } }))) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await run('wf_1', '--follow', '--select-output', 'agent_1.content') + + expect(requestRaw.mock.calls[0][1].body).toEqual({ + stream: true, + selectedOutputs: ['agent_1.content'], + }) + }) + it('leaves the generated non-streaming path untouched', async () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 7e47171a0e0..256e0567f75 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -342,6 +342,19 @@ function followOrDelegate(previous: ((args: unknown[]) => unknown) | null) { const flags = command.optsWithGlobals() as Record if (flags.follow !== true) { + // `selectedOutputs` is stream-only server-side, so without `--follow` the + // generated path spends a request to be told so. The recovery names the + // run resource and its dialect: `--select-output` here takes block names, + // and `workflows runs get` resolves block ids only, so repeating what was + // just typed there fails a second time. + if (Array.isArray(flags.selectOutput) && flags.selectOutput.length > 0) { + throw new SimApiError( + flags.async === true + ? '--select-output shapes a streamed result, and --async returns as soon as the run is queued, so there is no stream to shape. Drop one of them, or read the finished run with: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.' + : '--select-output shapes a streamed result; add --follow. To narrow a run that has already finished: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.', + 0 + ) + } if (flags.includeThinking === true || flags.includeToolCalls === true) { throw new SimApiError( '--include-thinking and --include-tool-calls describe a stream; add --follow', diff --git a/packages/sim-cli/src/commands/secrets.test.ts b/packages/sim-cli/src/commands/secrets.test.ts index c5e64fdbef7..92dc2e714c3 100644 --- a/packages/sim-cli/src/commands/secrets.test.ts +++ b/packages/sim-cli/src/commands/secrets.test.ts @@ -113,6 +113,18 @@ describe('secrets set', () => { }) }) + it('marks --scope required in the help it renders', () => { + // Commander enforces `makeOptionMandatory` but renders nothing to say so: + // the marker is the literal suffix the generated flags carry. + const secrets = program().commands.find((command) => command.name() === 'secrets') + const set = secrets?.commands.find((command) => command.name() === 'set') + if (!set) throw new Error('Missing secrets set command') + + expect(set.helpInformation().replace(/\s+/g, ' ')).toContain( + 'Secret ownership scope (required)' + ) + }) + it('keeps --value optional in help and rejects an empty direct value', async () => { const secrets = program().commands.find((command) => command.name() === 'secrets') const set = secrets?.commands.find((command) => command.name() === 'set') @@ -163,6 +175,38 @@ describe('secrets set --unredacted', () => { expect('unredacted' in sentBody()).toBe(false) }) + it('changes only the redaction setting, without prompting for a value', async () => { + // The shape a CI job runs: the secret already exists and only its redaction + // setting is changing, so there is no value to read and nothing to prompt. + await set('--scope', 'workspace', '--no-unredacted') + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(sentBody()).toEqual({ workspaceId: 'ws_local', scope: 'workspace', unredacted: false }) + expect('value' in sentBody()).toBe(false) + }) + + it('changes only the description, without prompting for a value', async () => { + await set('--scope', 'workspace', '--description', 'Billing key') + + expect(mockPromptSecret).not.toHaveBeenCalled() + expect(sentBody().description).toBe('Billing key') + expect('value' in sentBody()).toBe(false) + }) + + it('refuses both spellings of the redaction setting in one invocation', async () => { + // They share one commander attribute, so the loser is dropped silently — + // on the flag governing whether the value is readable in plaintext. + await expect( + set('--scope', 'workspace', '--value', 'v', '--unredacted', '--no-unredacted') + ).rejects.toThrow(/either --unredacted or --no-unredacted, not both/) + expect(mockRequest).not.toHaveBeenCalled() + + await expect( + set('--scope', 'workspace', '--value', 'v', '--no-unredacted', '--unredacted') + ).rejects.toThrow(/either --unredacted or --no-unredacted, not both/) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('rejects the flag for a personal secret before reading the value', async () => { await expect(set('--scope', 'personal', '--unredacted')).rejects.toThrow( '--unredacted is only supported for a workspace secret.' diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts index bf5b8634f18..57552c40050 100644 --- a/packages/sim-cli/src/commands/secrets.ts +++ b/packages/sim-cli/src/commands/secrets.ts @@ -76,7 +76,14 @@ function validateWorkspaceOnlyFlag( } /** - * Reads the secret, from the flag or the prompt. + * Reads the secret, from the flag or the prompt, or not at all. + * + * Nothing is read when the command carries a metadata flag and no `--value`: + * that invocation is changing the description or the redaction setting of a + * secret that already exists, and the API accepts a workspace body with no + * value. Prompting there hangs a CI job on stdin for a value it was never + * asked for — `sim secrets set NAME --no-unredacted` is the shape that has to + * work unattended. * * An abort at the prompt is reported here rather than thrown: Ctrl-C is the * user deciding not to run the command, and the shell's convention for that is @@ -84,8 +91,9 @@ function validateWorkspaceOnlyFlag( * exit is immediate for the same reason the handler's is — the prompt leaves * stdin listening, so a returning process would sit there instead of ending. */ -async function readSecretValue(options: SetSecretOptions): Promise { +async function readSecretValue(options: SetSecretOptions): Promise { if (options.value !== undefined) return validateSecretValue(readValueArgument(options.value)) + if (options.description !== undefined || options.unredacted !== undefined) return undefined try { return validateSecretValue(await promptSecret()) } catch (error) { @@ -95,7 +103,18 @@ async function readSecretValue(options: SetSecretOptions): Promise { } } -async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise { +async function setSecret( + name: string, + options: SetSecretOptions, + command: Command, + redactionSpellings: ReadonlySet +): Promise { + if (redactionSpellings.size > 1) { + throw new SimApiError( + 'Pass either --unredacted or --no-unredacted, not both: they are one setting, and commander keeps only whichever came last.', + 0 + ) + } const description = validateWorkspaceOnlyFlag('description', options.description, options.scope) const unredacted = validateWorkspaceOnlyFlag('unredacted', options.unredacted, options.scope) const value = await readSecretValue(options) @@ -106,7 +125,7 @@ async function setSecret(name: string, options: SetSecretOptions, command: Comma body: { workspaceId: client.requireWorkspace(), scope: options.scope, - value, + ...(value === undefined ? {} : { value }), description, ...(unredacted === undefined ? {} : { unredacted }), }, @@ -120,12 +139,22 @@ export function attachSecretCommands(program: Command): void { const secrets = program.commands.find((command) => command.name() === 'secrets') if (!secrets) throw new Error('The generated secrets command group is missing') + /** + * Which of the two spellings of the redaction setting were typed. + * + * They share one commander attribute, so passing both silently keeps the last + * one — on the flag that decides whether the value is readable in plaintext. + * The parsed options cannot show that, so the occurrences are recorded as + * commander reports them. + */ + const redactionSpellings = new Set() + secrets .command('set') .argument('', 'Secret name, as referenced in workflows') .description('Create or replace a named secret') .addOption( - new Option('--scope ', 'Secret ownership scope') + new Option('--scope ', 'Secret ownership scope (required)') .choices([...SECRET_SCOPES]) .makeOptionMandatory() ) @@ -142,7 +171,9 @@ export function attachSecretCommands(program: Command): void { `${V2_OPERATIONS.setSecret.body.unredacted.describe} Pass --no-unredacted to restore redaction` ) .option('--no-unredacted', 'Send --unredacted as false') + .on('option:unredacted', () => redactionSpellings.add('--unredacted')) + .on('option:no-unredacted', () => redactionSpellings.add('--no-unredacted')) .action((name: string, options: SetSecretOptions, command: Command) => - setSecret(name, options, command) + setSecret(name, options, command, redactionSpellings) ) } diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 86415595ddc..027ac1385ba 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -1,15 +1,46 @@ /** * @vitest-environment node */ -import type { Command } from 'commander' -import { describe, expect, it } from 'vitest' + +import { Command } from 'commander' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { HELP_EPILOGUE } from '../program' import { buildGeneratedCommands } from '../runtime/build' import { flagNameFor, flagSpecFor } from '../runtime/request' +import { renderPage } from '../runtime/result' import type { OperationSpec } from '../runtime/types' import { CLI_CONTRACT } from './commands' +const { mockRequest } = vi.hoisted(() => ({ mockRequest: vi.fn() })) + +vi.mock('../context', () => ({ + clientFrom: () => ({ + client: { request: mockRequest, requireWorkspace: () => 'ws_local' }, + profile: { workspaceId: 'ws_local', output: 'json', name: 'default', apiKey: 'k' }, + }), +})) + +/** + * Runs a leaf through commander, the way the terminal does. + * + * A `confirm` gate is enforced in `runtime/execute`, not by the contract, so + * asserting the string alone would only compare the constant with itself: the + * key could be renamed, or the gate could stop reading it, with the test still + * green. Parsing real argv is what proves the refusal reaches the caller and + * that nothing was sent. + */ +async function runLeaf(argv: string[]): Promise { + const root = new Command('sim').exitOverride().option('--workspace ') + for (const group of buildGeneratedCommands()) root.addCommand(group) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + await root.parseAsync(['node', 'sim', ...argv]) +} + /** Every leaf command's full path, `tables rows count` style. */ function leafPaths(options: { includeHidden?: boolean } = {}): string[] { const paths: string[] = [] @@ -307,6 +338,19 @@ describe('confirm gates say what is actually at stake', () => { expect(CLI_CONTRACT.rollbackWorkflow?.confirm).toBeTruthy() }) + it('gates the cancel that strands half a table, not the one that discards a file', () => { + // The import runner commits rows batch by batch and stops between batches, + // so a cancelled import keeps what it wrote; a `replace` has already + // emptied the table by then. The export only reads, so it stays ungated + // for the reason `workflows runs cancel` used to be. + const cancelImport = CLI_CONTRACT.cancelTableImport?.confirm ?? '' + + expect(cancelImport).toContain('replace') + expect(cancelImport).toMatch(/empties the table/) + expect(cancelImport).not.toMatch(/not recoverable|cannot be undone/) + expect(CLI_CONTRACT.cancelTableExport?.confirm).toBeUndefined() + }) + it('does not promise irreversible loss for a recoverable delete', () => { // `tables restore`, `knowledge restore` and `workflows restore` all ship, so // these three archive rather than destroy — the wording `deleteFile` @@ -350,6 +394,15 @@ describe('records show what the API actually returns', () => { expect(spec?.describe).toContain('personal API key') }) + it('says which ledger a billing-logs page answers', () => { + // The same workspace, window and flags return a strict subset of rows on a + // personal key, which reads as a bug beside `billing status`. + const summary = CLI_CONTRACT.listBillingLogs?.describe ?? '' + + expect(summary).toContain('personal API key') + expect(summary).toContain('workspace API key') + }) + it('describes a workspace with the fields the strict schema has', () => { const paths = (CLI_CONTRACT.getWorkspace?.fields ?? []).map( (field) => field.path ?? field.header @@ -395,6 +448,69 @@ describe('list columns', () => { expect(toolPaths).toContain('workflowId') }) + it('shows what a dispatch was asked to run', () => { + // A dispatch's `scope` is an object, and column inference drops those, so + // the filtered / select-all-minus / explicit-rows distinction the resource + // publishes had nowhere to appear. + const columns = CLI_CONTRACT.listTableDispatches?.columns ?? [] + const paths = columns.map((column) => column.path ?? column.header) + + expect(paths).toContain('scope.groupIds') + expect(paths).toContain('scope.rowIds') + expect(paths).toContain('scope.filtered') + expect(paths).toContain('scope.excludeRowIds') + // Kept from what inference used to show: the cap a dispatch ran under, and + // when it finished. `limit` is an object, so only its `max` is a column. + expect(paths).toContain('limit.max') + expect(paths).toContain('completedAt') + // Both are the command's own arguments, so neither earns a column. + expect(paths).not.toContain('tableId') + expect(paths).not.toContain('workspaceId') + }) + + it('renders the filtered and excluded distinction in the table it prints', () => { + const dispatch = { + id: 'disp-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + status: 'dispatching', + mode: 'incomplete', + scope: { groupIds: ['g1'], filtered: true, excludeRowIds: ['r9', 'r8'] }, + limit: { type: 'rows', max: 500 }, + processedCount: 12, + isManualRun: true, + requestedAt: '2026-08-25T10:00:00.000Z', + completedAt: null, + canceledAt: null, + } + const lines: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((line: unknown) => { + lines.push(String(line)) + }) + + try { + renderPage('table', [dispatch], CLI_CONTRACT.listTableDispatches ?? {}) + } finally { + log.mockRestore() + } + + const [header, printed] = lines.join('\n').split('\n') + // Split on the two-space column gap, so a cell is compared by the column it + // landed under rather than by appearing anywhere in the row. + const headers = header.split(/\s{2,}/) + const cells = printed.split(/\s{2,}/) + const cellUnder = (label: string) => cells[headers.indexOf(label)] + + expect(headers).toContain('FILTERED') + expect(headers).toContain('EXCLUDED') + expect(headers).not.toContain('WORKSPACE ID') + expect(cellUnder('FILTERED')).toBe('yes') + expect(cellUnder('EXCLUDED')).toBe('2') + expect(cellUnder('ROWS')).toBe('—') + expect(cellUnder('MAX ROWS')).toBe('500') + expect(cellUnder('COMPLETED')).toBe('—') + }) + it('lists the audit-log id its own get command takes', () => { expect( (CLI_CONTRACT.listAuditLogs?.columns ?? []).map((column) => column.path ?? column.header) @@ -570,6 +686,27 @@ describe('help and gates state what is actually true', () => { expect(confirm).toContain('--scope') }) + it('names the flag its stream requirement, the way its siblings do', () => { + // `--include-thinking` and `--include-tool-calls` both say so; the flag + // that shares their server-side rule said nothing and spent a 400 to + // discover it. + const help = flatHelp('workflows', 'run') + + expect(help).toContain('--select-output') + expect(help).toContain('requires --follow') + }) + + it('promises the dialect a finished run actually matches', () => { + // The same flag name on two resources: `workflows run` resolves block + // names against the live workflow, `workflows runs get` reads a recorded + // run and matches ids only. The help used to promise names on both. + const help = flatHelp('workflows', 'runs', 'get') + + expect(help).toContain('--select-output ') + expect(help).toContain('blockId') + expect(help).not.toMatch(/blockName|agent_1\.content/) + }) + it('offers no negation for the retry that must travel alone', () => { // `retryProcessing` is `z.literal(true)`, so `--no-retry-processing` sent // `retryProcessing: false` as the entire request — a body that asks for @@ -580,3 +717,28 @@ describe('help and gates state what is actually true', () => { expect(help).not.toContain('--no-retry-processing') }) }) + +describe('the import cancel refuses through commander, not just in the contract', () => { + beforeEach(() => { + mockRequest.mockReset() + mockRequest.mockResolvedValue({ data: {} }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('refuses an import cancellation without --yes and sends nothing', async () => { + await expect(runLeaf(['tables', 'imports', 'cancel', 'imp-1'])).rejects.toThrow(/--yes/) + expect(mockRequest).not.toHaveBeenCalled() + }) + + it('still lets an export cancellation through, since it discards no work', async () => { + await runLeaf(['tables', 'exports', 'cancel', 'tbl-1', 'exp-1']) + expect(mockRequest).toHaveBeenCalled() + }) + + it('offers --yes in the help of the one it now gates, and not its sibling', () => { + expect(flatHelp('tables', 'imports', 'cancel')).toContain( + 'Confirm this destructive operation (required)' + ) + expect(flatHelp('tables', 'exports', 'cancel')).not.toContain('--yes') + }) +}) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 40a7336d932..a0eacca9480 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -151,7 +151,12 @@ export const CLI_CONTRACT: CliContract = { listBillingLogs: { command: 'billing logs', allWorkspaces: true, - describe: 'List credit usage events', + // Which ledger answered depends on the key, and the counts otherwise read + // as a bug next to `billing status`. Said in the describe for the reason + // `billing status` says its own caveat. The trailing parenthetical is what + // keeps the generated docs heading unchanged. + describe: + "List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's)", flags: { source: { describe: 'Filter by usage source; sim-chat combines Copilot and workspace chat' }, period: { describe: 'Billing period' }, @@ -364,15 +369,21 @@ export const CLI_CONTRACT: CliContract = { describe: 'Include final output in JSON or YAML output (implies full detail)', }, }, + // The floors are for `logs follow`, which locks its widths on the first + // batch and had nothing to measure at `-n 0`. Each is what the column's own + // rendering needs: an ISO timestamp trimmed to seconds, the longest status + // and core trigger type, a UUID run id, a duration in minutes, and a + // four-decimal cost above ten credits. `workflow` is free text with no + // bound, so its floor is editorial — enough to tell two runs apart. columns: [ - { header: 'started', path: 'startedAt', format: 'timestamp' }, - { header: 'status' }, + { header: 'started', path: 'startedAt', format: 'timestamp', minWidth: 19 }, + { header: 'status', minWidth: 9 }, { header: 'level' }, - { header: 'trigger' }, - { header: 'workflow', path: 'workflow.name' }, - { header: 'duration', path: 'totalDurationMs', format: 'duration' }, - { header: 'cost', path: 'cost.total', format: 'cost' }, - { header: 'run', path: 'runId' }, + { header: 'trigger', minWidth: 12 }, + { header: 'workflow', path: 'workflow.name', minWidth: 24 }, + { header: 'duration', path: 'totalDurationMs', format: 'duration', minWidth: 8 }, + { header: 'cost', path: 'cost.total', format: 'cost', minWidth: 8 }, + { header: 'run', path: 'runId', minWidth: 36 }, ], }, getLog: { @@ -1297,6 +1308,33 @@ export const CLI_CONTRACT: CliContract = { }, }, }, + listTableDispatches: { + // Column inference drops every object-valued field, so `scope` — the whole + // point of the row — was invisible, and `limit` appeared or vanished with + // whether the first row happened to be capped. Declared instead, with the + // scope broken into the scalars that distinguish a plain dispatch from a + // filtered or select-all-minus one. `tableId` and `workspaceId` are gone: + // both are already the command's own arguments. The rest keep the order + // inference gave them and the scope columns are appended, because + // `--output text` is positional and a script may be cutting fields. + columns: [ + { header: 'id' }, + { header: 'status' }, + { header: 'mode' }, + // The cap is `{ type, max } | null`, and only `max` is a value: `type` is + // a `z.literal('rows')` that says the same thing on every row. + { header: 'max rows', path: 'limit.max' }, + { header: 'processed', path: 'processedCount' }, + { header: 'manual', path: 'isManualRun', format: 'bool' }, + { header: 'requested', path: 'requestedAt', format: 'timestamp' }, + { header: 'completed', path: 'completedAt', format: 'timestamp' }, + { header: 'canceled', path: 'canceledAt', format: 'timestamp' }, + { header: 'groups', path: 'scope.groupIds', format: 'count' }, + { header: 'rows', path: 'scope.rowIds', format: 'count' }, + { header: 'filtered', path: 'scope.filtered', format: 'bool' }, + { header: 'excluded', path: 'scope.excludeRowIds', format: 'count' }, + ], + }, runRowEnrichment: { command: 'tables rows enrich', describe: 'Run one row’s enrichment group', @@ -1317,8 +1355,26 @@ export const CLI_CONTRACT: CliContract = { // `resolveTableImportContext` takes when no token is sent — so the flag adds // a credential to type and no import it reaches. getTableImport: { flags: TRANSFER_TOKEN_OMITTED }, - cancelTableImport: { command: 'tables imports cancel', flags: TRANSFER_TOKEN_OMITTED }, - cancelTableExport: { command: 'tables exports cancel' }, + cancelTableImport: { + command: 'tables imports cancel', + flags: TRANSFER_TOKEN_OMITTED, + describe: 'Stop a running import', + // Gated for the reason `tables dispatches cancel` is: the runner commits + // rows batch by batch and its ownership gate stops it between batches, so + // there is never a state to resume from. The message has to hold for both + // modes, and `replace` is the destructive one — it empties the table before + // its first batch, so a cancelled replace leaves neither the old rows nor + // the whole file. + confirm: + 'This stops the import between row batches, so whatever it already wrote stays and nothing resumes it. A replace import empties the table before its first batch, so cancelling one leaves only part of the new file; an append adds its rows again if you import the file a second time.', + }, + cancelTableExport: { + command: 'tables exports cancel', + describe: 'Stop a running export', + // Not `confirm`-gated: the export only reads the table and writes a file + // nobody has yet, so cancelling discards nothing `tables exports create` + // cannot redo. + }, tableExportDownload: { // GET, but it returns a signed URL rather than a listing. command: 'tables exports download', @@ -1347,11 +1403,14 @@ export const CLI_CONTRACT: CliContract = { hidden: true, describe: 'Low-level workflow state and entry-point selection', }, + // Stream-only on the wire, so the requirement is stated where the flag + // is read rather than left to the 400. The dialect differs from the one + // `workflows runs get` takes, which is why both describes name theirs. selectedOutputs: { name: 'select-output', list: true, describe: - 'Return blockName.field values (e.g. agent_1.content); missing fields are omitted', + 'Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted', }, // SSE, not JSON — the generic client cannot consume it, so the response // encoding is chosen by `--follow`, which `workflow-run-follow.ts` adds to @@ -1385,10 +1444,14 @@ export const CLI_CONTRACT: CliContract = { boolean: true, describe: 'Include the final output in JSON or YAML output', }, + // A finished run is read back without loading the workflow, so the + // recorded block ids are all there is to match against — the block names + // `workflows run --select-output` accepts are rejected here. selectedOutputs: { name: 'select-output', list: true, - describe: 'Include blockName.field values in JSON or YAML output (e.g. agent_1.content)', + describe: + 'Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run', }, }, fields: [ @@ -1426,8 +1489,14 @@ export const CLI_CONTRACT: CliContract = { command: 'workflows runs cancel', pathFlags: WORKFLOW_RUN_SCOPE, describe: 'Cancel a running workflow run', - // Not `confirm`-gated: cancelling is recoverable (re-run it), and the - // whole point is to stop something that is already going wrong. + // Not `confirm`-gated: the whole point is to stop something that is + // already going wrong, and for an ordinary in-flight run `re-run it` is a + // real recovery. It is NOT one for a run paused for input — cancelling + // completes the pause, so the context `workflows runs resume` needs is gone + // with the block outputs it held, and starting over repeats every side + // effect the run already performed. Gating it is a live proposal rather + // than an oversight; it is left ungated here because `confirm` is + // all-or-nothing and cancel is the command most likely to be automated. }, resumeWorkflow: { command: 'workflows runs resume', diff --git a/packages/sim-cli/src/contract/types.ts b/packages/sim-cli/src/contract/types.ts index 93e1a355f34..901aa87a107 100644 --- a/packages/sim-cli/src/contract/types.ts +++ b/packages/sim-cli/src/contract/types.ts @@ -152,6 +152,20 @@ export interface ColumnSpec { header: string /** Dot path into the row. Defaults to `header`. */ path?: string + /** + * Narrowest this column may lock to when a renderer fixes its widths before + * it has seen the rows. + * + * `logs list` sizes every column from the page it is about to print, so it + * never needs this. A follow cannot: it locks the widths on its first batch so + * the stream reads as one table, and `logs follow -n 0` locks them on no rows + * at all — every column collapsed to its header label, and a run id printed as + * `9f…`. The floor is what the column's own rendering is known to need (a + * timestamp is 19 characters, a run id 36), so it is stated here beside the + * `format` that produces it rather than guessed by the renderer. Capped by the + * renderer's own maximum cell width; a floor above that is a spec bug. + */ + minWidth?: number /** * Rendering hint; `auto` inspects the value. * @@ -254,8 +268,9 @@ export interface CommandSpec { * key the whole workspace ledger — and the response says which. The value * belongs to the query rather than to any row, so it is not a column; it goes * to stderr so that a `--output text` consumer cutting tab-separated fields - * still reads only rows. The machine formats print the envelope whole and - * carry it already. + * still reads only rows. `json` and `yaml` print the unwrapped `data` array + * and so drop the field too, which is why the note is not limited to the + * human formats — see `runtime/result`. */ pageNote?: { path: string; label: string } /** Allow an optional workspaceId field to omit the configured workspace filter. */ diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 2a5dd8f52b9..1aad0d91e30 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1599,7 +1599,7 @@ export type CreateKnowledgeDocumentUploadBody = { tag6?: string tag7?: string processingOptions?: { - recipe?: string + recipe?: 'default' | 'plain' | 'markdown' | 'code' lang?: string } } @@ -7650,7 +7650,7 @@ export type SetSecretQuery = Record export type SetSecretBody = { workspaceId: string scope: 'workspace' | 'personal' - value: string + value?: string description?: string | null unredacted?: boolean } @@ -8940,6 +8940,9 @@ export type UpsertTableRowResponse = { * * `summary` is the operation's one-line description, lifted from the OpenAPI * specs so `--help` reuses prose that is already written and already checked. + * + * `personalKeyOnly` marks an operation whose spec description says a workspace + * API key is rejected, so `--help` can say so before the request is sent. */ export const V2_OPERATIONS = { abortFileUpload: { @@ -8999,6 +9002,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Activate Workflow Version', + personalKeyOnly: true, }, addTableColumn: { method: 'POST', @@ -9045,6 +9049,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Index Workspace Files', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9066,6 +9071,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Apply Workflow Operations', + personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -9171,6 +9177,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Save Tag Definitions', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9194,6 +9201,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Bulk Update Chunks', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9209,7 +9217,8 @@ export const V2_OPERATIONS = { chunkIds: { kind: 'array', required: true, - describe: 'Chunks to operate on, by identifier. Ids outside the document are ignored.', + describe: + 'Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request.', }, }, }, @@ -9220,6 +9229,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Bulk Enable or Disable Documents', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9446,6 +9456,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Credential Connection', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9623,6 +9634,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Create Chunk', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9648,6 +9660,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9763,6 +9776,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Create Tag', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9880,6 +9894,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Service-Account Credential', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -9919,6 +9934,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Skill', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -10137,6 +10153,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'Create Workflow MCP Server', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -10168,6 +10185,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to disconnect.' }, responseMode: 'json', summary: 'Disconnect Credential', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10259,6 +10277,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Chunk', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10277,6 +10296,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Knowledge Connector', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10348,6 +10368,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Tag', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10363,6 +10384,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Delete Tag Definitions', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10395,9 +10417,10 @@ export const V2_OPERATIONS = { method: 'DELETE', path: '/api/v2/secrets/[name]', pathParams: ['name'] as const, - pathParamDocs: { name: 'Secret to create, replace, or delete.' }, + pathParamDocs: { name: 'Secret to delete.' }, responseMode: 'json', summary: 'Delete Secret', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10424,6 +10447,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Delete Skill', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, }, @@ -10537,6 +10561,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Delete Workflow Chat Deployment', + personalKeyOnly: true, }, deleteWorkflowFolder: { method: 'DELETE', @@ -10588,6 +10613,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Delete Workflow MCP Server', + personalKeyOnly: true, }, deployWorkflow: { method: 'POST', @@ -10596,6 +10622,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Deploy Workflow', + personalKeyOnly: true, body: { name: { kind: 'string', describe: 'Optional label for the deployment version.' }, description: { @@ -10611,6 +10638,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Publish Workflow As MCP Tool', + personalKeyOnly: true, body: { workflowId: { kind: 'string', @@ -10763,6 +10791,7 @@ export const V2_OPERATIONS = { pathParamDocs: { auditLogId: 'Audit-log entry identifier.' }, responseMode: 'json', summary: 'Get Audit Log', + personalKeyOnly: true, query: { organizationId: { kind: 'string', @@ -10833,7 +10862,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.', + 'Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.', }, }, }, @@ -10896,6 +10925,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Chunk', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -10914,6 +10944,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Get Knowledge Connector', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11027,6 +11058,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'Get Next Tag Slot', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11199,6 +11231,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Get Workflow Chat Deployment', + personalKeyOnly: true, }, getWorkflowDeployment: { method: 'GET', @@ -11215,6 +11248,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Get Workflow MCP Server', + personalKeyOnly: true, }, getWorkflowRun: { method: 'GET', @@ -11285,6 +11319,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Grant Skill Editor', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -11326,6 +11361,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Audit Logs', + personalKeyOnly: true, query: { action: { kind: 'string', describe: 'Filter by exact action name.' }, resourceType: { @@ -11690,7 +11726,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.', + 'Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.', }, }, }, @@ -11831,6 +11867,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Chunks', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11882,6 +11919,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'List Knowledge Connector Documents', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -11912,6 +11950,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Knowledge Connectors', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12062,6 +12101,7 @@ export const V2_OPERATIONS = { pathParamDocs: { knowledgeBaseId: 'Unique knowledge base identifier.' }, responseMode: 'json', summary: 'List Tag Usage', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12241,6 +12281,7 @@ export const V2_OPERATIONS = { pathParamDocs: { mcpServerId: 'Unique MCP server identifier.' }, responseMode: 'json', summary: 'List MCP Server Tools', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12260,6 +12301,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Secrets', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12467,7 +12509,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + 'Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', }, folderPath: { kind: 'string', @@ -12619,6 +12661,7 @@ export const V2_OPERATIONS = { pathParams: [] as const, responseMode: 'json', summary: 'List Workflow MCP Servers', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -12658,6 +12701,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'List Workflow MCP Tools', + personalKeyOnly: true, }, listWorkflowRuns: { method: 'GET', @@ -13038,6 +13082,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Create or Replace Workflow Chat Deployment', + personalKeyOnly: true, body: { identifier: { kind: 'string', @@ -13093,6 +13138,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Replace Workflow State', + personalKeyOnly: true, query: { dryRun: { kind: 'boolean', @@ -13148,7 +13194,7 @@ export const V2_OPERATIONS = { kind: 'string', required: true, describe: - 'Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.', + 'Path of the archived folder to restore, as reported by an archived-scope folder list.', }, }, }, @@ -13193,7 +13239,7 @@ export const V2_OPERATIONS = { path: { kind: 'string', required: true, - describe: 'Path the folder held when `DELETE /api/v2/tables/folders` archived it.', + describe: 'Path the folder held when a folder delete archived it.', }, }, }, @@ -13234,6 +13280,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revert Workflow To Version', + personalKeyOnly: true, }, revokeSkillEditor: { method: 'DELETE', @@ -13245,6 +13292,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Revoke Skill Editor', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, email: { @@ -13261,6 +13309,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Rollback Workflow', + personalKeyOnly: true, body: { version: { kind: 'integer', @@ -13363,9 +13412,10 @@ export const V2_OPERATIONS = { method: 'PUT', path: '/api/v2/secrets/[name]', pathParams: ['name'] as const, - pathParamDocs: { name: 'Secret to create, replace, or delete.' }, + pathParamDocs: { name: 'Secret to create or replace.' }, responseMode: 'json', summary: 'Set Secret', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13382,8 +13432,8 @@ export const V2_OPERATIONS = { }, value: { kind: 'string', - required: true, - describe: 'Write-only secret value. It is never returned.', + describe: + 'Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.', }, description: { kind: 'string', @@ -13407,6 +13457,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Sync Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13445,6 +13496,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Undeploy Workflow', + personalKeyOnly: true, }, undeployWorkflowMcpTool: { method: 'DELETE', @@ -13456,6 +13508,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Unpublish Workflow MCP Tool', + personalKeyOnly: true, }, unzipFile: { method: 'POST', @@ -13475,6 +13528,7 @@ export const V2_OPERATIONS = { pathParamDocs: { credentialId: 'Credential to update.' }, responseMode: 'json', summary: 'Update Credential', + personalKeyOnly: true, query: { workspaceId: { kind: 'string', @@ -13577,6 +13631,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Chunk', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13604,6 +13659,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13636,6 +13692,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Knowledge Connector Documents', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13665,6 +13722,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Document', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13710,6 +13768,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Tag', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', @@ -13824,6 +13883,7 @@ export const V2_OPERATIONS = { }, responseMode: 'json', summary: 'Update Skill', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the skill.' }, name: { kind: 'string', describe: 'New kebab-case skill name.' }, @@ -13963,6 +14023,7 @@ export const V2_OPERATIONS = { pathParamDocs: { serverId: 'Unique workflow-MCP server identifier.' }, responseMode: 'json', summary: 'Update Workflow MCP Server', + personalKeyOnly: true, body: { name: { kind: 'string', describe: 'Server display name, shown to connecting MCP clients.' }, description: { kind: 'string', describe: 'New server description, or null to clear it.' }, @@ -13979,6 +14040,7 @@ export const V2_OPERATIONS = { pathParamDocs: { workflowId: 'Unique workflow identifier.' }, responseMode: 'json', summary: 'Update Workflow Public API Access', + personalKeyOnly: true, body: { isPublicApi: { kind: 'boolean', @@ -14028,6 +14090,7 @@ export const V2_OPERATIONS = { pathParamDocs: { fileId: 'File identifier.' }, responseMode: 'json', summary: 'Enable or Disable File Share', + personalKeyOnly: true, body: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, isActive: { @@ -14067,7 +14130,7 @@ export const V2_OPERATIONS = { kind: 'object', required: true, describe: - 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.', + 'Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.', }, conflictTarget: { kind: 'string', describe: 'Unique column used to detect a conflict.' }, }, diff --git a/packages/sim-cli/src/program.test.ts b/packages/sim-cli/src/program.test.ts index 56d2153e7ba..b3916507243 100644 --- a/packages/sim-cli/src/program.test.ts +++ b/packages/sim-cli/src/program.test.ts @@ -85,3 +85,50 @@ describe('the root version flag', () => { expect(help).toContain('takes no value') }) }) + +/** + * `sim --help` exited 0 printing the group's help, so a probe + * that reads the exit code to ask "does this command exist?" was told yes. + */ +describe('help typed after a command that does not exist', () => { + it('refuses it inside a group', async () => { + const { out, code } = await parse(['workspaces', 'zzzz', '--help']) + + expect(code).toBe('commander.unknownCommand') + expect(out).not.toContain('Manage workspaces') + }) + + it('refuses a command the group never had', async () => { + const { code } = await parse(['chat-deployments', 'get', '--help']) + + expect(code).toBe('commander.unknownCommand') + }) + + it('refuses it at the root', async () => { + const { code } = await parse(['zzzz', '--help']) + + expect(code).toBe('commander.unknownCommand') + }) + + it('still answers help for a group and for its commands', async () => { + const group = await parse(['workspaces', '--help']) + expect(group.code).toBe('commander.helpDisplayed') + expect(group.out).toContain('Usage: sim workspaces') + + const leaf = await parse(['workspaces', 'get', '--help']) + expect(leaf.code).toBe('commander.helpDisplayed') + expect(leaf.out).toContain('Usage: sim workspaces get') + }) + + /** + * Both exclusions are load-bearing: `files restore` registers a positional + * while hosting a subcommand, and `profiles` acts on its own, so an operand + * there is not an unknown command. + */ + it('leaves a command that legitimately takes an operand alone', async () => { + expect((await parse(['files', 'restore', 'wf_1', '--help'])).code).toBe( + 'commander.helpDisplayed' + ) + expect((await parse(['profiles', 'zzzz', '--help'])).code).toBe('commander.helpDisplayed') + }) +}) From 8db2bd6ba3372b69a45562e16b3bcc9b59f76dfb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:50:31 -0700 Subject: [PATCH 11/15] fix: close the gaps an adversarial review of this branch found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conflict handler added earlier in this branch was dead code. It read the Postgres error code off the thrown object, but the driver error arrives wrapped with the real one on `cause`, so the check returned false on its first line and the 409 never fired. Its test passed only because it threw a flat shape production never produces. It now reads through the cause chain with the shared helpers, compares the constraint name exactly instead of matching a substring of the SQL, and its test throws the real wrapped error. Resuming a conversation checked its workflow and its workspace but not its type, so a conversation created by the web surface could be continued as a CLI turn. It now refuses through the same uniform 404 as every other mismatch, which closes the same omission on the web posting path. Minting one no longer leaves a blank untitled row at the top of the Chat list. The pre-write check on a minted API key refused fewer characters than the writer does, so a key the check accepted could still fail at the write — after the endpoint beside it was already stored, pairing a new endpoint with the previous key. The two had drifted because the set was spelled three times; there is now one. A description claimed a processed count reported only the chunks that changed. The update returns every row it matched, so re-enabling chunks that were already enabled counts them all. Two OpenAPI sentences promised no conflict detection and no persistence warnings in a dry run, both of which the same branch had just made false. A described window was wrong whenever a start was supplied without an end. Listing the editors of a built-in skill answered a read with a modification refusal on the internal surface. Archived table listings could reach the strict folder projector again through a third scope value the input type still allowed. A metadata-only secret write skipped the guard its personal-scope twin has. The internal document boundary still took the two processing fields as unbounded strings. Truncation was reported only from the response envelope, so a clipped file body, row search and workflow-stats list said nothing. A staged download stopped watching for signals before it finished removing its directory, and cleared every listener for the signal rather than its own. Three tests asserted a contract constant against itself; they now drive rendered help, real argv, or real render output. --- apps/docs/content/docs/en/cli/scripting.mdx | 32 ++++++++- apps/sim/app/api/mothership/chats/route.ts | 3 +- apps/sim/app/api/v2/chat/route.test.ts | 36 ++++++++++ apps/sim/app/api/v2/chat/route.ts | 26 +++++-- .../v2/workspaces/[workspaceId]/route.test.ts | 4 +- .../api/contracts/knowledge/documents.test.ts | 52 ++++++++++++++ .../lib/api/contracts/knowledge/documents.ts | 15 +--- .../transport-neutral-descriptions.test.ts | 54 ++++++++------ apps/sim/lib/api/contracts/v2/files.ts | 2 +- .../lib/api/contracts/v2/knowledge-chunks.ts | 8 ++- apps/sim/lib/api/contracts/v2/logs-stats.ts | 2 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 2 +- .../lib/api/contracts/v2/openapi/tables.ts | 3 +- .../lib/api/contracts/v2/openapi/workflows.ts | 4 +- .../api/contracts/v2/workflow-mcp-servers.ts | 2 +- apps/sim/lib/copilot/chat/lifecycle.test.ts | 41 +++++++++++ apps/sim/lib/copilot/chat/lifecycle.ts | 20 +++++- apps/sim/lib/copilot/constants.ts | 7 ++ .../lib/secrets/application/use-cases.test.ts | 16 +++++ apps/sim/lib/secrets/application/use-cases.ts | 13 ++++ .../application/editor-use-cases.test.ts | 20 ++++++ apps/sim/lib/skills/application/use-cases.ts | 46 +++++++++--- apps/sim/lib/table/application/tables.ts | 12 +++- .../apply-workflow-operations.test.ts | 49 +++++++++++++ .../application/apply-workflow-operations.ts | 22 +++++- .../replace-normalized-state.test.ts | 45 ++++++++++-- .../persistence/replace-normalized-state.ts | 23 +++--- packages/sim-cli/src/commands/auth.test.ts | 63 ++++++++++++++-- packages/sim-cli/src/commands/auth.ts | 51 +++++++++++-- .../src/commands/protocol/files-get.test.ts | 71 +++++++++++++++++++ .../src/commands/protocol/files-get.ts | 67 +++++++++-------- packages/sim-cli/src/commands/secrets.ts | 14 +++- packages/sim-cli/src/config/index.ts | 1 + packages/sim-cli/src/config/ini.test.ts | 22 ++++++ packages/sim-cli/src/config/ini.ts | 21 +++++- packages/sim-cli/src/config/profile.ts | 10 ++- .../sim-cli/src/contract/commands.test.ts | 69 ++++++++---------- packages/sim-cli/src/contract/commands.ts | 20 +++--- packages/sim-cli/src/runtime/result.test.ts | 52 ++++++++++++++ packages/sim-cli/src/runtime/result.ts | 62 +++++++++++++--- 40 files changed, 887 insertions(+), 195 deletions(-) diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 0f10192c396..c1f38b95589 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -31,6 +31,20 @@ printf 'wf_3Qm8ZtLpR2yVnKd7BsXwC\nwf_5Hn1JvTqW9xUcMb4RzPgL\n' | sim files mv --f Arrays of objects stay JSON. +## Passing a literal leading `@` + +Because `@` introduces a file reference, a value that genuinely starts with one +is written `@@`. Only the leading `@` is dropped, and every `@`-aware flag +accepts the escape: + +```bash +sim files share set wf_3Qm8ZtLpR2yVnKd7BsXwC --allowed-emails @@example.org +sim secrets set API_HOST --value @@internal +``` + +Without it, `--allowed-emails @example.org` can only be read as a request to +open a file named `example.org`. + ## Filtering table rows `--filter` takes the same predicate tree the API uses: `all` (AND) or `any` (OR) @@ -117,11 +131,23 @@ esac ## Selecting workflow output -`--select-output` takes `blockName.field` selectors. Fields that a run did not -produce are simply omitted: +`--select-output` shapes a streamed result, so it requires `--follow`. It takes +`blockName.field` selectors; fields that a run did not produce are simply +omitted: + +```bash +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --follow --select-output agent_1.content --output json +``` + +Without `--follow` the CLI refuses the pair rather than spending a request on a +response that carries no outputs, and `--async` cannot be combined with it +either — there is no stream to shape. To narrow a run that has already finished, +read it back with `workflows runs get`, which matches block **ids** rather than +the block names `workflows run` takes: ```bash -sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --select-output agent_1.content --output json +sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 \ + --select-output 1d4c8f02-7b63-4a19-8e52-63f0a7c5d9b1.content --output json ``` ## Polling a long run diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 8a9a60ef0fa..acdbfddb2a9 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -9,6 +9,7 @@ import { import { parseRequest } from '@/lib/api/server' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' import { chatPubSub } from '@/lib/copilot/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, createForbiddenResponse, @@ -78,7 +79,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { workspaceId, type: 'mothership', title: null, - model: 'claude-opus-4-8', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, updatedAt: now, lastSeenAt: now, }) diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 76f5803ae4d..97ace3a9819 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -325,6 +325,42 @@ describe('POST /api/v2/chat', () => { expect(mockRunHeadlessCopilotLifecycle).not.toHaveBeenCalled() }) + it('asks the resolver for a mothership conversation, so another type resolves to nothing', async () => { + await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ chatId: OWNED_CONVERSATION_ID, type: 'mothership' }) + ) + }) + + it('titles a new conversation by its first message so the web Chat list has no blank row', async () => { + await callChat({ workspaceId: 'workspace-1', message: ' Summarize\n last week ' }) + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Summarize last week' }) + ) + }) + + it('truncates a long first message into a title instead of storing the whole message', async () => { + const message = 'a'.repeat(500) + + await callChat({ workspaceId: 'workspace-1', message }) + + const title = (mockResolveOrCreateChat.mock.calls[0][0] as { title?: string }).title + expect(title).toBe(`${'a'.repeat(80)}...`) + }) + + it('leaves the title unset for a whitespace-only message rather than stamping an empty one', async () => { + await callChat({ workspaceId: 'workspace-1', message: ' \n ' }) + + const resolverInput = mockResolveOrCreateChat.mock.calls[0][0] as Record + expect(Object.hasOwn(resolverInput, 'title')).toBe(false) + }) + it('rejects a malformed conversation id before resolving anything', async () => { const response = await callChat({ workspaceId: 'workspace-1', diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 46095172b14..a1e59cd8705 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { v2ChatContract } from '@/lib/api/contracts/v2/chat' import { parseRequest } from '@/lib/api/server' @@ -16,6 +17,7 @@ import { chatOperations } from '@/lib/copilot/application/operations' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { type CopilotEnvironmentContext, @@ -50,10 +52,23 @@ const CHAT_HEARTBEAT_INTERVAL_MS = 15_000 const ndjsonEncoder = new TextEncoder() /** - * Model recorded on conversations this route creates, matching the model the - * web Chat surface stamps on a new mothership conversation. + * Longest title derived from a first message. Well under the 200-character + * ceiling the rename contract enforces, and short enough to read as one line + * in the web Chat list. */ -const V2_CHAT_MODEL = 'claude-opus-4-8' +const CHAT_TITLE_MAX_LENGTH = 80 + +/** + * Title a conversation this route creates by its first message, so a `sim chat` + * turn does not leave a blank row at the top of the user's web Chat list. + * Returns undefined for a message that is only whitespace, leaving the title + * unset rather than stamping an empty one. + */ +function deriveConversationTitle(message: string): string | undefined { + const normalized = message.replace(/\s+/g, ' ').trim() + if (!normalized) return undefined + return truncate(normalized, CHAT_TITLE_MAX_LENGTH) +} function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === 'AbortError' @@ -145,6 +160,8 @@ export const POST = withRouteHandler( const workspaceAccess = await assertActiveWorkspaceAccess(workspaceId, userId) const userPermission = workspaceAccess.permission + const conversationTitle = deriveConversationTitle(message) + // A caller-supplied conversation id is a claim, not an identity: resolve // it through the same owner- and workspace-scoped loader the web Chat // surface uses, and refuse every id that does not resolve with the same @@ -154,8 +171,9 @@ export const POST = withRouteHandler( ...(conversationId ? { chatId: conversationId } : {}), userId, workspaceId, - model: V2_CHAT_MODEL, + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, type: 'mothership', + ...(conversationTitle ? { title: conversationTitle } : {}), }) if (conversationId && !resolvedChat.chat) { return v2Error('NOT_FOUND', 'Conversation not found') diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts index 2257e097400..a0a0b4f2c93 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.test.ts @@ -121,6 +121,8 @@ describe.each(routes)('v2 $name workspace concealment', ({ spy, call }) => { const response = await call() expect(response.status).toBe(403) - expect(await response.json()).toMatchObject({ error: { code: 'FORBIDDEN' } }) + expect(await response.json()).toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } }, + }) }) }) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.test.ts b/apps/sim/lib/api/contracts/knowledge/documents.test.ts index f39274830ce..61fbb6cc3dd 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { + bulkCreateDocumentsBodySchema, listKnowledgeDocumentsQuerySchema, parseDocumentTagFiltersParam, + upsertDocumentBodySchema, } from '@/lib/api/contracts/knowledge/documents' describe('listKnowledgeDocumentsQuerySchema.tagFilters', () => { @@ -143,3 +145,53 @@ describe('parseDocumentTagFiltersParam', () => { expect(parseDocumentTagFiltersParam(JSON.stringify(filters))).toEqual(filters) }) }) + +/** + * `recipe` and `lang` reach analytics and nothing else, so an unrecognised value + * was accepted with a 200 and silently discarded. Both internal write bodies + * reuse the upload boundary's validated shape, which is what every first-party + * caller already sends. + */ +describe('internal document processingOptions', () => { + const DOCUMENT = { + filename: 'notes.txt', + fileUrl: 'https://example.com/notes.txt', + fileSize: 12, + mimeType: 'text/plain', + } + + const boundaries = [ + { + name: 'bulk create', + parse: (processingOptions: unknown) => + bulkCreateDocumentsBodySchema.safeParse({ + documents: [DOCUMENT], + bulk: true, + processingOptions, + }), + }, + { + name: 'upsert', + parse: (processingOptions: unknown) => + upsertDocumentBodySchema.safeParse({ ...DOCUMENT, processingOptions }), + }, + ] as const + + describe.each(boundaries)('$name', ({ parse }) => { + it('accepts what the shipped first-party callers send', () => { + expect(parse({ recipe: 'default', lang: 'en' }).success).toBe(true) + }) + + it('rejects an unrecognised recipe instead of discarding it', () => { + const result = parse({ recipe: 'super-chunker-9000', lang: 'en' }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'recipe']) + }) + + it('rejects a lang that is not a BCP-47 tag', () => { + const result = parse({ recipe: 'default', lang: 'en_US' }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'lang']) + }) + }) +}) diff --git a/apps/sim/lib/api/contracts/knowledge/documents.ts b/apps/sim/lib/api/contracts/knowledge/documents.ts index 028526b7654..bc8ad27d841 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.ts @@ -17,6 +17,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' import { PRIVATE_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata' import { getFieldTypeForSlot, MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE } from '@/lib/knowledge/constants' import { getOperatorsForFieldType, isValidFilterValue } from '@/lib/knowledge/filters/types' +import { knowledgeDocumentUploadMetadataSchema } from '@/lib/knowledge/upload-metadata' export const documentTagFilterSchema = z .object({ @@ -136,12 +137,7 @@ export const bulkCreateDocumentsBodySchema = z.object({ MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE, `At most ${MAX_KNOWLEDGE_DOCUMENTS_PER_CREATE} documents may be created at once` ), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), + processingOptions: knowledgeDocumentUploadMetadataSchema.shape.processingOptions, bulk: z.literal(true), workflowId: z.string().optional(), [PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(), @@ -173,12 +169,7 @@ export const upsertDocumentBodySchema = z.object({ fileSize: z.number().min(1, 'File size must be greater than 0'), mimeType: z.string().min(1, 'MIME type is required'), documentTagsData: z.string().optional(), - processingOptions: z - .object({ - recipe: z.string().optional(), - lang: z.string().optional(), - }) - .optional(), + processingOptions: knowledgeDocumentUploadMetadataSchema.shape.processingOptions, workflowId: z.string().optional(), [PRIVATE_SECRET_PROVENANCE_FIELD]: privateSecretProvenanceBundleSchema.optional(), }) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts index b45a7031154..10a3125753d 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/transport-neutral-descriptions.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-sweep' +import { MAX_SCHEMA_DEPTH } from '@/lib/api/contracts/v2/__tests__/schema-introspection' /** * Every v2 schema description is user-facing prose on two surfaces at once: the @@ -24,58 +25,61 @@ import { listContractFiles } from '@/lib/api/contracts/v2/__tests__/contract-swe const ENDPOINT_SPELLING = /\b(GET|POST|PATCH|PUT|DELETE)\s+\// -/** Depth cap so a self-referential `lazy` schema cannot spin the walk. */ -const MAX_DEPTH = 12 - /** - * Descriptions still naming a transport. Every entry is a contract file owned by - * another change in flight — none is a judgment that the spelling is correct. + * Descriptions still naming a transport, deferred rather than endorsed. Each one + * lives in a v2 contract file this change does not touch, and the reason names + * that file so a later pass knows where to go. The second test below fails as + * soon as one of these stops offending, so a fix elsewhere cannot leave a stale + * entry behind. */ const ALLOWED = new Map([ [ 'Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read.', - 'knowledge tag contracts owned elsewhere', + 'not touched here: lives in v2/knowledge.ts', ], [ 'Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.', - 'knowledge tag contracts owned elsewhere', + 'not touched here: lives in v2/knowledge-tags.ts', ], [ 'Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.', - 'knowledge.ts owned elsewhere', + 'not touched here: lives in v2/knowledge.ts', ], [ 'ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.', - 'knowledge.ts owned elsewhere', + 'not touched here: lives in v2/knowledge.ts', ], [ 'Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', - 'knowledge.ts owned elsewhere', + 'not touched here: lives in v2/knowledge.ts', ], [ 'Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.', - 'knowledge.ts owned elsewhere', + 'not touched here: lives in v2/knowledge.ts', ], [ 'Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction.', - 'workflows.ts owned elsewhere', + 'not touched here: lives in v2/workflows.ts', ], [ 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back.', - 'workflows.ts owned elsewhere', + 'not touched here: lives in v2/workflows.ts', ], [ 'Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`.', - 'workflows.ts owned elsewhere', + 'not touched here: lives in v2/workflows.ts', ], [ 'Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.', - 'workflows.ts owned elsewhere', + 'not touched here: lives in v2/workflows.ts', + ], + [ + 'Custom tool id returned by `GET /api/v2/custom-tools`.', + 'not touched here: lives in v2/workflows.ts', ], - ['Custom tool id returned by `GET /api/v2/custom-tools`.', 'workflows.ts owned elsewhere'], [ 'Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`.', - 'workflows.ts owned elsewhere', + 'not touched here: lives in v2/workflows.ts', ], ]) @@ -104,6 +108,16 @@ function collect(node: unknown, key: string, seen: Set, out: Described[ for (const wrapper of ['innerType', 'in', 'out', 'schema', 'element', 'valueType', 'keyType']) { if (def[wrapper]) collect(def[wrapper], key, seen, out, depth - 1) } + if (typeof def.getter === 'function') { + /** + * A `lazy` schema hides its shape behind a getter, and the depth cap is what + * keeps a self-referential one from spinning. A getter that throws is not + * this sweep's business, so it is skipped rather than failing the run. + */ + try { + collect((def.getter as () => unknown)(), key, seen, out, depth - 1) + } catch {} + } for (const option of (def.options as unknown[] | undefined) ?? []) { collect(option, key, seen, out, depth - 1) } @@ -130,7 +144,7 @@ async function sweepDescriptions(): Promise { const seen = new Set() const key = `${name}#${exported}` if ('def' in value) { - collect(value, key, seen, out, MAX_DEPTH) + collect(value, key, seen, out, MAX_SCHEMA_DEPTH) continue } const contract = value as { @@ -141,10 +155,10 @@ async function sweepDescriptions(): Promise { response?: { schema?: unknown } } for (const slot of ['params', 'query', 'body', 'headers'] as const) { - if (contract[slot]) collect(contract[slot], `${key}.${slot}`, seen, out, MAX_DEPTH) + if (contract[slot]) collect(contract[slot], `${key}.${slot}`, seen, out, MAX_SCHEMA_DEPTH) } if (contract.response?.schema) { - collect(contract.response.schema, `${key}.response`, seen, out, MAX_DEPTH) + collect(contract.response.schema, `${key}.response`, seen, out, MAX_SCHEMA_DEPTH) } } } diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index f07e0557bff..c94c5c10940 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -344,7 +344,7 @@ export const v2ListFilesQuerySchema = z scope: v2FileScopeSchema .default('active') .describe( - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' ), search: v2SearchSchema.describe('Case-insensitive substring match against the file name.'), ...v2SortFields(v2FileSortFields, { sortBy: 'uploadedAt', sortOrder: 'asc' }), diff --git a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts index 653270c27df..e9602911bff 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge-chunks.ts @@ -213,7 +213,9 @@ export type V2BulkKnowledgeChunksBody = z.input { expect(result.conversationHistory).toEqual([userMsg, asstMsg]) }) + it('resolveOrCreateChat refuses a resumed chat whose type is not the asserted one', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, type: 'mothership' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([]) + + const result = await resolveOrCreateChat({ + chatId: CHAT_ID, + userId: USER_ID, + model: 'm', + type: 'copilot', + }) + + // Same shape an unknown id resolves to: the refusal carries no reason. + expect(result.chat).toBeNull() + expect(result.conversationHistory).toEqual([]) + expect(result.isNew).toBe(false) + }) + + it('resolveOrCreateChat resumes a chat whose type matches the asserted one', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ ...chatRow, type: 'mothership' }]) + dbChainMockFns.orderBy.mockResolvedValueOnce([{ content: userMsg }]) + + const result = await resolveOrCreateChat({ + chatId: CHAT_ID, + userId: USER_ID, + model: 'm', + type: 'mothership', + }) + + expect(result.chat).not.toBeNull() + expect(result.conversationHistory).toEqual([userMsg]) + }) + + it('resolveOrCreateChat stamps a supplied title on a newly created chat', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([chatRow]) + + await resolveOrCreateChat({ userId: USER_ID, model: 'm', title: 'First message' }) + + const insertValues = dbChainMockFns.values.mock.calls[0]?.[0] as Record + expect(insertValues.title).toBe('First message') + }) + it('resolveOrCreateChat creates a new chat with an empty transcript', async () => { dbChainMockFns.returning.mockResolvedValueOnce([chatRow]) diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts index 69b577a31e9..b6701675be3 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/copilot/chat/lifecycle.ts @@ -231,6 +231,11 @@ export async function getAccessibleCopilotChatWithMessages( * Resolve or create a copilot chat session. * If chatId is provided, loads the existing chat. Otherwise creates a new one. * Supports both workflow-scoped and workspace-scoped chats. + * + * A resumed chat must match every scope the caller asserted — workflow, + * workspace, and `type`. Any mismatch resolves to `chat: null`, exactly as an + * unknown id does, so callers cannot distinguish the reasons a chat did not + * resolve. `title` is stamped only on a newly created chat. */ export async function resolveOrCreateChat(params: { chatId?: string @@ -239,8 +244,9 @@ export async function resolveOrCreateChat(params: { workspaceId?: string model: string type?: 'mothership' | 'copilot' + title?: string }): Promise { - const { chatId, userId, workflowId, workspaceId, model, type } = params + const { chatId, userId, workflowId, workspaceId, model, type, title } = params if (workspaceId) { await assertActiveWorkspaceAccess(workspaceId, userId) @@ -270,6 +276,16 @@ export async function resolveOrCreateChat(params: { return { chatId, chat: null, conversationHistory: [], isNew: false } } + if (type && chat.type !== type) { + logger.warn('Copilot chat type mismatch', { + chatId, + userId, + requestType: type, + chatType: chat.type, + }) + return { chatId, chat: null, conversationHistory: [], isNew: false } + } + if (chat.workflowId) { const activeWorkflow = await getActiveWorkflowRecord(chat.workflowId) if (!activeWorkflow) { @@ -299,7 +315,7 @@ export async function resolveOrCreateChat(params: { ...(workflowId ? { workflowId } : {}), ...(workspaceId ? { workspaceId } : {}), type: type ?? 'copilot', - title: null, + title: title ?? null, model, lastSeenAt: now, }) diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 1df1be6c40e..91f018596ac 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -77,3 +77,10 @@ export const TOOL_RESULT_MAX_INLINE_CHARS = export const COPILOT_MODES = ['ask', 'build', 'plan'] as const export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const + +/** + * Model stamped on a newly created mothership conversation, by the web Chat + * surface and by the `sim chat` API alike. Shared so the two creation paths + * cannot drift onto different models for the same conversation type. + */ +export const MOTHERSHIP_CHAT_DEFAULT_MODEL = 'claude-opus-4-8' diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index e9519ea0807..4a921ab23ac 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -512,6 +512,22 @@ describe('secret application use cases', () => { expect(mocks.setWorkspace).not.toHaveBeenCalled() }) + it('refuses a workspace write that names none of the three writable fields', async () => { + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + }, + }) + ).rejects.toThrow(/value, description, or unredacted is required/) + + expect(mocks.updateWorkspaceMetadata).not.toHaveBeenCalled() + expect(mocks.setWorkspace).not.toHaveBeenCalled() + }) + it('refuses a value-less personal write in the use case, not just the contract', async () => { await expect( setSecretUseCase.execute({ diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 495acf7f17e..29d63bba5ef 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -321,6 +321,19 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ * something it did not create. */ if (input.value === undefined) { + /** + * A write that names none of the three writable fields would still issue + * the UPDATE, stamping `updatedAt` and dropping the workspace's env cache + * entry for nothing. The contract rejects it; this repeats the guard for + * every other surface that reaches the use case directly. + */ + if (input.description === undefined && input.unredacted === undefined) { + throw new OrchestrationError( + 'validation', + 'value, description, or unredacted is required' + ) + } + const metadata = await updateWorkspaceSecretMetadata({ workspaceId: context.workspaceId, name: input.name, diff --git a/apps/sim/lib/skills/application/editor-use-cases.test.ts b/apps/sim/lib/skills/application/editor-use-cases.test.ts index d0a49152c31..df969aeb040 100644 --- a/apps/sim/lib/skills/application/editor-use-cases.test.ts +++ b/apps/sim/lib/skills/application/editor-use-cases.test.ts @@ -350,5 +350,25 @@ describe('skill editor application use cases', () => { message: 'Built-in skills are read-only and cannot be modified', }) }) + + /** + * The internal members route maps no workspace id, so the list used to fall + * through to the mutation resolver and answer a read with the read-only + * refusal — the same incoherence the empty roster was added to remove. + */ + it('never answers a workspace-less list with the read-only refusal', async () => { + await expect( + listSkillEditorsUseCase.execute({ + principal, + input: { skillId: BUILTIN_ID, sortBy: 'email', sortOrder: 'asc' }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: 'workspaceId is required to list the editors of a built-in skill', + }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.listEditors).not.toHaveBeenCalled() + }) }) }) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 31565116735..43f7bfb1886 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -58,6 +58,24 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } +/** + * Resolves a workspace-owned skill for any editor verb, deriving the scope from + * the skill's own row when the caller asserted none. Both the mutation and the + * list resolver build on it, so neither has to route through the other. + */ +async function resolveOwnedSkillEditorContext( + skillId: string, + assertedWorkspaceId?: string +): Promise { + const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) + if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) { + throw new OrchestrationError('not_found', 'Skill not found') + } + + const workspace = await resolveWorkspaceContext(row.workspaceId) + return { ...workspace, skill: row } +} + /** * Resolves the skill an editor MUTATION addresses. * @@ -77,27 +95,33 @@ async function resolveSkillEditorContext( ) } - const [row] = await db.select().from(skill).where(eq(skill.id, skillId)).limit(1) - if (!row?.workspaceId || (assertedWorkspaceId && row.workspaceId !== assertedWorkspaceId)) { - throw new OrchestrationError('not_found', 'Skill not found') - } - - const workspace = await resolveWorkspaceContext(row.workspaceId) - return { ...workspace, skill: row } + return resolveOwnedSkillEditorContext(skillId, assertedWorkspaceId) } /** * Resolves the skill an editor LIST addresses. * * Listing is a read, so a built-in id is not a malformed request: the skill is - * real and `skills get` returns it. It resolves through the workspace the - * caller asserted, and the roster it reports is empty. + * real and `skills get` returns it. It never falls through to the mutation + * resolver, so the read can never answer that the caller tried to modify a + * read-only skill. + * + * A built-in skill owns no row, so there is no workspace to derive scope from + * and nothing to authorize the caller against. The read therefore requires the + * caller to name the workspace rather than guessing one: an inferred workspace + * would authorize against a scope the caller never asserted. */ async function resolveSkillEditorListContext(input: ListSkillEditorsInput): Promise { - if (isBuiltinSkillId(input.skillId) && input.workspaceId) { + if (isBuiltinSkillId(input.skillId)) { + if (!input.workspaceId) { + throw new OrchestrationError( + 'validation', + 'workspaceId is required to list the editors of a built-in skill' + ) + } return resolveSkillContext(input.workspaceId, input.skillId) } - return resolveSkillEditorContext(input.skillId, input.workspaceId) + return resolveOwnedSkillEditorContext(input.skillId, input.workspaceId) } const authorizationOptions = { delegation: skillDelegationPolicy } diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index d304efbe378..a71b1d64dcd 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -17,7 +17,6 @@ import { restoreTable, type TableDefinition, type TableSchema, - type TableScope, updateTableDescription, } from '@/lib/table' import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' @@ -36,8 +35,15 @@ import { signalTableSchemaChanged } from '@/lib/table/events' export interface ListTablesInput { workspaceId: string - /** Which lifecycle set to list. Omitted means `active`, matching every shipped caller. */ - scope?: TableScope + /** + * Which lifecycle set to list. Omitted means `active`, matching every shipped + * caller. Deliberately narrower than the `TableScope` the query layer takes: + * its third value, `'all'`, would mix archived rows into a page projected by + * the strict folder-path resolver, which throws on the dangling `folderId` a + * folder archive leaves behind. + * Mirrors `ListWorkflowsInput['scope']`. + */ + scope?: 'active' | 'archived' folderPath?: string search?: string sortBy: V2TableSortBy diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 16a704798b4..32189bcdb0e 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -22,6 +22,8 @@ const mocks = vi.hoisted(() => ({ preValidate: vi.fn(), collectReferences: vi.fn(), collectToolReferences: vi.fn(), + assertIdsUnclaimed: vi.fn(), + collectGraphIds: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -46,12 +48,15 @@ vi.mock('@/lib/workflows/application/context', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ replaceWorkflowNormalizedState: mocks.replace, + assertWorkflowGraphIdsUnclaimed: mocks.assertIdsUnclaimed, + collectWorkflowGraphIds: mocks.collectGraphIds, })) vi.mock('@/lib/workflows/persistence/utils', () => ({ loadWorkflowFromNormalizedTables: mocks.loadNormalized, })) vi.mock('@/lib/workflows/sanitization/validation', () => ({ validateWorkflowState: mocks.validate, + sanitizeAgentToolsInBlocks: (blocks: Record) => ({ blocks, warnings: [] }), })) vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mocks.needsRedeployment, @@ -104,6 +109,7 @@ vi.mock('@/lib/workflows/autolayout', () => ({ })) import { ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' @@ -150,6 +156,8 @@ function graph(blocks: Record = { 'block-1': BLOCK }) { return { blocks, edges: [], loops: {}, parallels: {} } } +const GRAPH_IDS = { blockIds: ['block-1'], edgeIds: [], subflowIds: [] } + describe('applyWorkflowOperations', () => { beforeEach(() => { vi.clearAllMocks() @@ -172,6 +180,8 @@ describe('applyWorkflowOperations', () => { mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) mocks.needsRedeployment.mockResolvedValue(true) + mocks.collectGraphIds.mockReturnValue(GRAPH_IDS) + mocks.assertIdsUnclaimed.mockResolvedValue(undefined) }) it('writes once, through the shared persistence primitive', async () => { @@ -232,6 +242,45 @@ describe('applyWorkflowOperations', () => { expect(mocks.notify).not.toHaveBeenCalled() }) + /** + * The commit goes through `replaceWorkflowNormalizedState`, whose in- + * transaction pre-check refuses a graph id another workflow already owns. + * A dry run that skips it reports success for a body whose commit is a 409. + */ + it('checks the ids the commit would insert before reporting success', async () => { + await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(mocks.collectGraphIds).toHaveBeenCalledWith( + expect.objectContaining({ + blocks: { 'block-1': { ...BLOCK, height: 0, horizontalHandles: true } }, + edges: [], + }) + ) + expect(mocks.assertIdsUnclaimed).toHaveBeenCalledWith( + expect.anything(), + 'workflow-1', + GRAPH_IDS + ) + }) + + it('refuses a dry run whose commit would conflict on a claimed id', async () => { + const conflict = new OrchestrationError( + 'conflict', + 'Block ids already used by another workflow: block-1' + ) + mocks.assertIdsUnclaimed.mockRejectedValue(conflict) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + ).rejects.toBe(conflict) + }) + /** The preview is worthless if it does not carry the findings. */ it('reports the same lint a committed apply would', async () => { const dry = await applyWorkflowOperations.execute({ diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index 45f4a208967..d83d8e5d6ee 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' @@ -44,7 +45,12 @@ import { type ValidationError, } from '@/lib/workflows/editing/types' import { preValidateCredentialInputs } from '@/lib/workflows/editing/validation' -import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' +import { + assertWorkflowGraphIdsUnclaimed, + collectWorkflowGraphIds, + replaceWorkflowNormalizedState, +} from '@/lib/workflows/persistence/replace-normalized-state' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { withBlockVisibility } from '@/blocks/visibility/server-context' @@ -358,6 +364,20 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ * dry run reports precisely what a committed apply of the same body would. */ if (input.dryRun) { + /** + * The id check the committed write runs, on the ids that write would + * actually insert — the prepared graph, not the engine's output — so a + * dry run cannot report success for a body whose commit is refused with + * a conflict. + */ + await assertWorkflowGraphIdsUnclaimed( + db, + context.workflowId, + collectWorkflowGraphIds( + prepareWorkflowStateForPersistence({ blocks: graph.blocks, edges: graph.edges }).state + ) + ) + logger.info('Evaluated workflow operations without persisting', { workflowId: context.workflowId, workspaceId: context.workspaceId, diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 80d1e9d7759..3c3541c0fba 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -3,6 +3,7 @@ */ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { and, inArray, ne } from 'drizzle-orm' +import { DrizzleQueryError } from 'drizzle-orm/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -53,6 +54,27 @@ function input(overrides: Record = {}) { } as Parameters[0] } +/** + * The shape production actually throws: Drizzle wraps every driver fault in a + * `DrizzleQueryError` whose own message is the SQL text and which carries no + * `code`, putting the driver error on `.cause`. A flat error object is a shape + * the database layer never produces. + */ +function wrapDriverError(cause: Error): Error { + return new DrizzleQueryError( + 'insert into "workflow_edges" ("id", "workflow_id") values ($1, $2)', + ['edge-1', 'workflow-1'], + cause + ) +} + +function uniqueViolation(constraintName: string): Error { + return Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + constraint_name: constraintName, + }) +} + describe('replaceWorkflowNormalizedState', () => { beforeEach(() => { vi.clearAllMocks() @@ -255,20 +277,29 @@ describe('replaceWorkflowNormalizedState', () => { * 409 rather than an unclassified 500. */ it('re-classifies a 23505 that races past the pre-check', async () => { - mocks.save.mockRejectedValue( - Object.assign(new Error('duplicate key value violates unique constraint'), { - code: '23505', - constraint_name: 'workflow_edges_pkey', - }) - ) + mocks.save.mockRejectedValue(wrapDriverError(uniqueViolation('workflow_edges_pkey'))) await expect(replaceWorkflowNormalizedState(input())).rejects.toMatchObject({ code: 'conflict', }) }) + /** + * The constraint name is compared exactly: a unique index whose name merely + * contains one of the graph-id constraints belongs to some other table and + * must not be reported as a claimed graph id. + */ + it('leaves a 23505 on an unrelated constraint unclassified', async () => { + const failure = wrapDriverError(uniqueViolation('archive_workflow_blocks_pkey_backup')) + mocks.save.mockRejectedValue(failure) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) + }) + it('leaves an unrelated database fault unclassified', async () => { - const failure = Object.assign(new Error('deadlock detected'), { code: '40P01' }) + const failure = wrapDriverError( + Object.assign(new Error('deadlock detected'), { code: '40P01' }) + ) mocks.save.mockRejectedValue(failure) await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 8aed3125c64..4c37da30c3c 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getPostgresConstraintName, getPostgresErrorCode } from '@sim/utils/errors' import { and, eq, inArray, isNull, ne } from 'drizzle-orm' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' @@ -151,20 +152,18 @@ const GRAPH_ID_CONSTRAINTS = [ * row lock covers the target workflow, not the workflow that would claim an id, * so under READ COMMITTED two concurrent writes carrying the same fresh id can * both pass the check and one insert still faults. This keeps that race a 409. + * + * Read through the `cause` chain: Drizzle wraps every driver fault in a + * `DrizzleQueryError` whose own message is the SQL text and whose `code` is + * absent, so a top-level field read never sees the driver's SQLSTATE. The + * constraint name is compared exactly rather than searched for in a message, + * so an unrelated constraint merely containing one of these names is not + * misclassified. */ function isGraphIdUniqueViolation(error: unknown): boolean { - if (!error || typeof error !== 'object') return false - const candidate = error as { - code?: unknown - constraint_name?: unknown - constraint?: unknown - message?: unknown - } - if (candidate.code !== UNIQUE_VIOLATION) return false - const parts = [candidate.constraint_name, candidate.constraint, candidate.message].filter( - (part): part is string => typeof part === 'string' - ) - return parts.some((part) => GRAPH_ID_CONSTRAINTS.some((name) => part.includes(name))) + if (getPostgresErrorCode(error) !== UNIQUE_VIOLATION) return false + const constraint = getPostgresConstraintName(error) + return GRAPH_ID_CONSTRAINTS.some((name) => name === constraint) } export interface ReplaceWorkflowState { diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 7a824aeaabe..c0d936266e2 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -42,16 +42,20 @@ vi.mock('../auth/device-flow', () => ({ pollForKey: mocks.pollForKey, })) /** - * The two validators come from the real module rather than a copy: a duplicated - * pattern here would keep passing if the shipped one were deleted, which is - * exactly the regression these tests exist to catch. `../config/profile` is not + * The validators and the format list come from the real module rather than a + * copy: a duplicated pattern here would keep passing if the shipped one were + * deleted, which is exactly the regression these tests exist to catch. `../config/profile` is not * itself mocked, so this is the shipped implementation. */ vi.mock('../config/index', async () => ({ - ...(await import('../config/profile').then(({ normalizeWorkspaceId, validateProfileName }) => ({ - normalizeWorkspaceId, - validateProfileName, - }))), + ...(await import('../config/profile').then( + ({ FORBIDDEN_IN_VALUE, normalizeWorkspaceId, OUTPUT_FORMATS, validateProfileName }) => ({ + FORBIDDEN_IN_VALUE, + normalizeWorkspaceId, + OUTPUT_FORMATS, + validateProfileName, + }) + )), configPath: () => '/tmp/sim-config', credentialsPath: () => '/tmp/sim-credentials', DEFAULT_PROFILE: 'default', @@ -289,6 +293,27 @@ describe('login command', () => { expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() }) + it.each([ + ['a C0 control character', 'sim-key\u0001rest'], + ['a Unicode line separator', 'sim-key\u2028rest'], + ])('stores nothing when the minted key carries %s', async (_label, apiKey) => { + // The pre-write check has to refuse exactly what the writer refuses. When it + // was the narrower of the two, the settings write landed and the credentials + // write threw — leaving the new endpoint on disk beside the previous key. + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + apiKey, + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + }) + + await expect(login()).rejects.toThrow('malformed API key') + + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + it('clears a stale workspace default when none is selected during login', async () => { setInteractive(false) mocks.profileFrom.mockReturnValue({ @@ -592,6 +617,30 @@ describe('profiles command', () => { expect(JSON.parse(vi.mocked(console.log).mock.calls.flat().join('\n'))).toEqual([]) }) + it('lists a broken active profile instead of refusing to list at all', async () => { + // Resolving the active profile supplies the row marker and the format, but + // it throws for exactly the profile this command exists to show. + mocks.listProfiles.mockReturnValue(['broken', 'default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Profile "broken" references missing auth_profile "gone".') + }) + mocks.resolveAuthenticationProfileName.mockImplementation((profile) => { + if (profile === 'broken') { + throw new mocks.ProfileConfigError( + 'Profile "broken" references missing auth_profile "gone".' + ) + } + return profile + }) + + await profiles('list', '--profile', 'broken') + + const output = vi.mocked(console.log).mock.calls.flat().join('\n') + expect(output).toContain('broken') + expect(output).toContain('default') + expect(output).toContain('references missing auth_profile "gone"') + }) + it('marks a broken profile and still lists the rest', async () => { // `profiles` is the command someone runs *because* a profile is broken, and // one bad auth_profile used to abort the listing with nothing shown at all. diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 2f1cd0135fe..ae81d573494 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -13,9 +13,12 @@ import { credentialsPath, DEFAULT_PROFILE, deleteProfile, + FORBIDDEN_IN_VALUE, listAuthenticationDependents, listProfiles, normalizeWorkspaceId, + OUTPUT_FORMATS, + type OutputFormat, ProfileConfigError, type ResolvedProfile, readCredentialsProfile, @@ -127,9 +130,13 @@ function validateNewProfileName(profileName: string): void { * whatever the endpoint names. A key carrying a line break would be written * verbatim into an escape-less format, so the writer refuses it — this refuses * it one step earlier, before anything is on disk, and says which side is wrong. + * + * It shares {@link FORBIDDEN_IN_VALUE} with the writer rather than copying it: + * a second spelling drifted once already, and a key this check accepted but the + * writer rejected stranded the new endpoint beside the previous key. */ function requireStorableKey(apiKey: unknown): void { - if (typeof apiKey !== 'string' || !apiKey.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(apiKey)) { + if (typeof apiKey !== 'string' || !apiKey.trim() || FORBIDDEN_IN_VALUE.test(apiKey)) { throw new SimApiError( 'The server returned a malformed API key. Nothing was stored; check the endpoint.', 0 @@ -659,6 +666,38 @@ function buildProfileRow(name: string, active: boolean): ProfileRow { } } +/** + * The active profile's name and the format to render in, tolerating a profile + * that does not resolve. + * + * Resolving the active profile is what supplies both, but it can also throw — + * and `profiles` is the command someone runs *because* a profile is broken, so + * a broken active profile has to appear as a marked row like any other rather + * than abort the listing. An unknown *name* is still refused: `profiles + * --profile typo` must fail like every other command instead of listing under a + * name that means nothing. + */ +function profileListingContext(command: Command): { activeName: string; output: OutputFormat } { + try { + const profile = profileFrom(command) + return { activeName: profile.name, output: profile.output } + } catch (error) { + if (!(error instanceof ProfileConfigError)) throw error + + const globals = globalsOf(command) + const named = globals.profile || process.env.SIM_PROFILE + if (named && named !== DEFAULT_PROFILE && !listProfiles().includes(named)) throw error + + const requested = globals.output ?? process.env.SIM_OUTPUT + return { + activeName: named || DEFAULT_PROFILE, + output: (OUTPUT_FORMATS as readonly string[]).includes(requested as string) + ? (requested as OutputFormat) + : 'table', + } + } +} + export function profilesCommand(): Command { const command = new Command('profiles') .alias('profile') @@ -668,18 +707,18 @@ export function profilesCommand(): Command { // Resolving is what makes `profiles --profile typo` fail like every other // command instead of listing happily under a name that resolves to nothing, // and it is also what supplies the output format the listing renders in. - const profile = profileFrom(actionCommand) - const rows = listProfiles().map((name) => buildProfileRow(name, name === profile.name)) + const { activeName, output } = profileListingContext(actionCommand) + const rows = listProfiles().map((name) => buildProfileRow(name, name === activeName)) if (rows.length === 0) { // The prose belongs to the human formats; a script asking for json must // get an empty list, not a sentence it cannot parse. - if (profile.output === 'table') console.log(chalk.dim('No profiles yet. Run: sim login')) - else printList(profile.output, rows, PROFILE_COLUMNS) + if (output === 'table') console.log(chalk.dim('No profiles yet. Run: sim login')) + else printList(output, rows, PROFILE_COLUMNS) return } - printList(profile.output, rows, PROFILE_COLUMNS) + printList(output, rows, PROFILE_COLUMNS) } command.action(printProfiles) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 2fec95b8576..d6d4041bb3f 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -116,6 +116,33 @@ describe('an interrupted download', () => { expect(terminate).toHaveBeenCalledWith('SIGINT') }) + /** + * The handler drops itself before terminating, rather than clearing the + * signal: the process has to die by that signal, and `removeAllListeners` + * bought that by taking every other handler on the process down with it. + */ + it('clears only its own listener before terminating', () => { + const foreign = vi.fn() + process.on('SIGINT', foreign) + const baseline = process.listenerCount('SIGINT') + let remaining: unknown[] = [] + const terminate = vi.fn(() => { + remaining = process.listeners('SIGINT') + }) + const dispose = removeStagingOnSignal(() => null, terminate) + + try { + process.emit('SIGINT') + } finally { + dispose() + process.off('SIGINT', foreign) + } + + expect(terminate).toHaveBeenCalledWith('SIGINT') + expect(remaining).toContain(foreign) + expect(remaining).toHaveLength(baseline) + }) + it('watches for signals only while a download is staged', async () => { const before = { int: process.listenerCount('SIGINT'), term: process.listenerCount('SIGTERM') } let observed = 0 @@ -134,6 +161,50 @@ describe('an interrupted download', () => { expect(process.listenerCount('SIGTERM')).toBe(before.term) }) + /** Resolves once the download has staged its directory beside the target. */ + async function stagingDirectory(): Promise { + for (let attempt = 0; attempt < 2000; attempt += 1) { + const [staged] = stagingDirectories() + if (staged) return join(dir, staged) + await new Promise((resolve) => setTimeout(resolve, 1)) + } + throw new Error('the download staged no directory') + } + + /** + * The removal of the staging directory is asynchronous, so it is a window the + * watch has to outlive: a handler disposed before it leaves the directory + * behind for exactly the interrupt the watch exists to catch. The directory is + * padded first so the removal spans enough turns to sample. + */ + it('keeps watching for signals until the staging directory is gone', async () => { + const before = process.listenerCount('SIGINT') + const body = new ReadableStream({ + async pull(controller) { + const staging = await stagingDirectory() + for (let index = 0; index < 2000; index += 1) { + writeFileSync(join(staging, `pad-${index}`), '') + } + controller.enqueue(new TextEncoder().encode('data')) + controller.close() + }, + }) + const samples: number[] = [] + const poll = setInterval(() => { + if (stagingDirectories().length > 0) samples.push(process.listenerCount('SIGINT')) + }) + + try { + await saveToFile(body, join(dir, 'out.bin'), false) + } finally { + clearInterval(poll) + } + + expect(samples.length).toBeGreaterThan(0) + expect(samples.filter((count) => count !== before + 1)).toEqual([]) + expect(process.listenerCount('SIGINT')).toBe(before) + }) + it('disposes the watch when the publish itself fails', async () => { const target = join(dir, 'out.txt') writeFileSync(target, 'precious') diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index 83ba08f9e47..6392c66c7b9 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -98,10 +98,12 @@ const STAGE_SIGNALS: readonly NodeJS.Signals[] = ['SIGINT', 'SIGTERM'] * Installing a listener suppresses Node's default termination, so the handler * has to terminate itself. Re-raising rather than `process.exit(130)` keeps the * process dying *by signal*, so a wrapping shell still sees 130/143 and a - * `trap` still fires — the behaviour an interrupted download has today. + * `trap` still fires — the behaviour an interrupted download has today. Only + * our own listener is removed, by the handler itself before it calls this: + * `removeAllListeners` would take a caller's own handler with it, and nothing + * here needs one gone but ours. */ function reRaise(signal: NodeJS.Signals): void { - process.removeAllListeners(signal) process.kill(process.pid, signal) } @@ -123,6 +125,7 @@ export function removeStagingOnSignal( ): () => void { const installed = STAGE_SIGNALS.map((signal) => { const onSignal = () => { + process.off(signal, onSignal) const directory = stagingDirectory() if (directory) { try { @@ -153,40 +156,44 @@ async function saveStagedFile( const disposeSignalCleanup = removeStagingOnSignal(() => temporaryDirectory) try { - const publicationTarget = force ? await forcedPublicationTarget(target) : target - temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) - const temporaryPath = join(temporaryDirectory, 'payload') - await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) - if (force) { - await rename(temporaryPath, publicationTarget) - } else { + try { + const publicationTarget = force ? await forcedPublicationTarget(target) : target + temporaryDirectory = await mkdtemp(join(dirname(publicationTarget), '.sim-download-')) + const temporaryPath = join(temporaryDirectory, 'payload') + await streamToFile(body, createWriteStream(temporaryPath, { flags: 'wx' }), target) + if (force) { + await rename(temporaryPath, publicationTarget) + } else { + try { + await link(temporaryPath, publicationTarget) + } catch (error) { + throw unsupportedAtomicPublish(target, error) ?? error + } + } + } catch (error) { + failure = normalizedWriteFailure(target, error) + } + + if (temporaryDirectory) { try { - await link(temporaryPath, publicationTarget) - } catch (error) { - throw unsupportedAtomicPublish(target, error) ?? error + await rm(temporaryDirectory, { recursive: true, force: true }) + } catch (cleanupError) { + if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) + throw new SimApiError( + `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, + 0 + ) } } - } catch (error) { - failure = normalizedWriteFailure(target, error) + + if (failure) throw failure } finally { - // Disposed on every path out, including the publish failure below: a - // handler left installed would outlive the directory it removes. + // Disposed on every path out, and only once the removal above has finished: + // an `rm` is asynchronous, so a handler disposed before it awaits leaves the + // staging directory behind for a signal arriving in exactly the window this + // watch exists for. disposeSignalCleanup() } - - if (temporaryDirectory) { - try { - await rm(temporaryDirectory, { recursive: true, force: true }) - } catch (cleanupError) { - if (failure) throw combinedCleanupFailure(failure, temporaryDirectory, cleanupError) - throw new SimApiError( - `Saved ${target}, but could not remove temporary directory ${temporaryDirectory}: ${(cleanupError as Error).message}`, - 0 - ) - } - } - - if (failure) throw failure } /** Publishes a complete staged body atomically, with overwrite requiring explicit force. */ diff --git a/packages/sim-cli/src/commands/secrets.ts b/packages/sim-cli/src/commands/secrets.ts index 57552c40050..83312008e63 100644 --- a/packages/sim-cli/src/commands/secrets.ts +++ b/packages/sim-cli/src/commands/secrets.ts @@ -81,9 +81,17 @@ function validateWorkspaceOnlyFlag( * Nothing is read when the command carries a metadata flag and no `--value`: * that invocation is changing the description or the redaction setting of a * secret that already exists, and the API accepts a workspace body with no - * value. Prompting there hangs a CI job on stdin for a value it was never - * asked for — `sim secrets set NAME --no-unredacted` is the shape that has to - * work unattended. + * value. Prompting there refused the invocation rather than losing anything: + * off a TTY {@link promptSecret} throws `Interactive secret input requires a + * terminal` before reading a byte, so `sim secrets set NAME --no-unredacted` + * exited 1 in CI for a value it was never asked for, and on a TTY it stopped + * to ask for one — a prompt that rejects an empty entry, so there was no way + * to answer "leave the stored value alone". Skipping the read is what lets a + * metadata-only edit run unattended and without a stored value to re-type. + * + * The knock-on, on a TTY: `sim secrets set NAME --description ...` used to + * prompt for a value and now updates the description alone. A value still + * travels by `--value`, or by the prompt when no metadata flag is passed. * * An abort at the prompt is reported here rather than thrown: Ctrl-C is the * user deciding not to run the command, and the shell's convention for that is diff --git a/packages/sim-cli/src/config/index.ts b/packages/sim-cli/src/config/index.ts index 1d6a3da6d86..414ea6abfee 100644 --- a/packages/sim-cli/src/config/index.ts +++ b/packages/sim-cli/src/config/index.ts @@ -3,6 +3,7 @@ export { DEFAULT_ENDPOINT, DEFAULT_PROFILE, deleteProfile, + FORBIDDEN_IN_VALUE, listAuthenticationDependents, listProfiles, normalizeEndpoint, diff --git a/packages/sim-cli/src/config/ini.test.ts b/packages/sim-cli/src/config/ini.test.ts index b9f7441af61..974c70c7dfc 100644 --- a/packages/sim-cli/src/config/ini.test.ts +++ b/packages/sim-cli/src/config/ini.test.ts @@ -236,6 +236,28 @@ describe('ini write guards', () => { expect(() => setSectionValues(doc, 'default', { workspace: ' ' })).toThrow(/blank value/) }) + /** + * The reader trims a section name and a value, so padded text would be stored + * as one thing and read back as another: the read reports it missing, and the + * next write appends a second block or key rather than updating the first. + */ + it.each([' profile dev', 'profile dev ', ' profile dev '])( + 'refuses the padded section name %j', + (name) => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, name, { workspace: 'ws_1' })).toThrow( + /Refusing to write a section/ + ) + } + ) + + it.each([' ws_1', 'ws_1 ', ' ws_1 '])('refuses the padded value %j', (value) => { + const doc = parseIni(SAMPLE) + expect(() => setSectionValues(doc, 'default', { workspace: value })).toThrow( + /Refusing to write a value/ + ) + }) + it('leaves a legitimate value untouched', () => { const doc = parseIni(SAMPLE) setSectionValues(doc, 'profile staging-1.eu', { endpoint: 'https://staging.example' }) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts index a71f047c38b..6f27c4406ab 100644 --- a/packages/sim-cli/src/config/ini.ts +++ b/packages/sim-cli/src/config/ini.ts @@ -59,8 +59,13 @@ const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ * silently vanishes on the next read although the write reported success, and * because the dead line no longer matches the key, the write after that appends * a duplicate. + * + * Exported because callers that refuse untrusted text *before* writing — so they + * can say which side produced it — have to refuse exactly this set. A second + * hand-kept copy drifted from this one once already, and the gap let a rejected + * write land after an accepted one. */ -const FORBIDDEN_IN_VALUE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ +export const FORBIDDEN_IN_VALUE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ /** As {@link FORBIDDEN_IN_VALUE}, plus the brackets that would close or open a header. */ const FORBIDDEN_IN_NAME = /[\u0000-\u001f\u007f-\u009f\u2028\u2029[\]]/ @@ -83,6 +88,15 @@ function assertWritable(text: string, what: string, forbidden: RegExp): void { `Refusing to write ${what}: line breaks and control characters cannot be stored in the ~/.sim files, because the format has no way to escape them.` ) } + // The reader trims both section names and values, so padded text comes back + // as something else: the read reports the setting missing although the write + // reported success, and the next write appends a second block or key instead + // of updating the one already there. + if (text !== text.trim()) { + throw new ProfileConfigError( + `Refusing to write ${what}: leading or trailing whitespace is not preserved by the ~/.sim files, so it would not read back as written.` + ) + } } export function parseIni(text: string): IniDocument { @@ -192,12 +206,13 @@ export function setSectionValues( throw new ProfileConfigError(`Refusing to write an unreadable setting name "${key}".`) } if (value === null) continue - assertWritable(value, `a value for "${key}"`, FORBIDDEN_IN_VALUE) // A value that is only whitespace reads back as the empty string, so the - // key would look stored and resolve as unset. + // key would look stored and resolve as unset. Checked before the general + // rule below so it keeps its own, more specific message. if (value.trim() === '') { throw new ProfileConfigError(`Refusing to write a blank value for "${key}".`) } + assertWritable(value, `a value for "${key}"`, FORBIDDEN_IN_VALUE) } const matching = doc.sections.filter((s) => s.name === name) diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 7359e1b91e8..8492ec2458e 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname } from 'node:path' import { + FORBIDDEN_IN_VALUE, getSection, type IniDocument, listSections, @@ -36,7 +37,10 @@ export const DEFAULT_ENDPOINT = 'https://www.sim.ai' export const OUTPUT_FORMATS = ['table', 'json', 'yaml', 'text'] as const export type OutputFormat = (typeof OUTPUT_FORMATS)[number] -export { ProfileConfigError } from './ini' +export { FORBIDDEN_IN_VALUE, ProfileConfigError } from './ini' + +/** {@link FORBIDDEN_IN_VALUE}, for redacting every match out of an error message. */ +const FORBIDDEN_IN_VALUE_GLOBAL = new RegExp(FORBIDDEN_IN_VALUE.source, 'g') /** * The shape a newly created profile name has to have. @@ -337,9 +341,9 @@ export function normalizeWorkspaceId(workspaceId: string, source: string): strin if (!trimmed) { throw new ProfileConfigError(`Empty workspace id from ${source}.`) } - if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(trimmed)) { + if (FORBIDDEN_IN_VALUE.test(trimmed)) { throw new ProfileConfigError( - `Invalid workspace id "${trimmed.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')}" from ${source}. A workspace id cannot contain line breaks or control characters.` + `Invalid workspace id "${trimmed.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. A workspace id cannot contain line breaks or control characters.` ) } return trimmed diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 027ac1385ba..73486d2f0b1 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -338,19 +338,6 @@ describe('confirm gates say what is actually at stake', () => { expect(CLI_CONTRACT.rollbackWorkflow?.confirm).toBeTruthy() }) - it('gates the cancel that strands half a table, not the one that discards a file', () => { - // The import runner commits rows batch by batch and stops between batches, - // so a cancelled import keeps what it wrote; a `replace` has already - // emptied the table by then. The export only reads, so it stays ungated - // for the reason `workflows runs cancel` used to be. - const cancelImport = CLI_CONTRACT.cancelTableImport?.confirm ?? '' - - expect(cancelImport).toContain('replace') - expect(cancelImport).toMatch(/empties the table/) - expect(cancelImport).not.toMatch(/not recoverable|cannot be undone/) - expect(CLI_CONTRACT.cancelTableExport?.confirm).toBeUndefined() - }) - it('does not promise irreversible loss for a recoverable delete', () => { // `tables restore`, `knowledge restore` and `workflows restore` all ship, so // these three archive rather than destroy — the wording `deleteFile` @@ -396,11 +383,13 @@ describe('records show what the API actually returns', () => { it('says which ledger a billing-logs page answers', () => { // The same workspace, window and flags return a strict subset of rows on a - // personal key, which reads as a bug beside `billing status`. - const summary = CLI_CONTRACT.listBillingLogs?.describe ?? '' + // personal key, which reads as a bug beside `billing status`. Read off the + // help the terminal prints, since a describe that never reached a command + // would answer nobody. + const help = flatHelp('billing', 'logs') - expect(summary).toContain('personal API key') - expect(summary).toContain('workspace API key') + expect(help).toContain('personal API key reports only your own events') + expect(help).toMatch(/workspace API key reports every member/) }) it('describes a workspace with the fields the strict schema has', () => { @@ -448,27 +437,13 @@ describe('list columns', () => { expect(toolPaths).toContain('workflowId') }) - it('shows what a dispatch was asked to run', () => { - // A dispatch's `scope` is an object, and column inference drops those, so - // the filtered / select-all-minus / explicit-rows distinction the resource - // publishes had nowhere to appear. - const columns = CLI_CONTRACT.listTableDispatches?.columns ?? [] - const paths = columns.map((column) => column.path ?? column.header) - - expect(paths).toContain('scope.groupIds') - expect(paths).toContain('scope.rowIds') - expect(paths).toContain('scope.filtered') - expect(paths).toContain('scope.excludeRowIds') - // Kept from what inference used to show: the cap a dispatch ran under, and - // when it finished. `limit` is an object, so only its `max` is a column. - expect(paths).toContain('limit.max') - expect(paths).toContain('completedAt') - // Both are the command's own arguments, so neither earns a column. - expect(paths).not.toContain('tableId') - expect(paths).not.toContain('workspaceId') - }) - - it('renders the filtered and excluded distinction in the table it prints', () => { + /** + * A dispatch's `scope` and `limit` are objects, and column inference drops + * those, so the filtered / select-all-minus / explicit-rows distinction the + * resource publishes had nowhere to appear — while `tableId` and + * `workspaceId`, the command's own arguments, took two columns. + */ + it('renders what a dispatch was asked to run, and drops its own arguments', () => { const dispatch = { id: 'disp-1', tableId: 'tbl-1', @@ -504,8 +479,10 @@ describe('list columns', () => { expect(headers).toContain('FILTERED') expect(headers).toContain('EXCLUDED') expect(headers).not.toContain('WORKSPACE ID') + expect(headers).not.toContain('TABLE ID') expect(cellUnder('FILTERED')).toBe('yes') expect(cellUnder('EXCLUDED')).toBe('2') + expect(cellUnder('GROUPS')).toBe('1') expect(cellUnder('ROWS')).toBe('—') expect(cellUnder('MAX ROWS')).toBe('500') expect(cellUnder('COMPLETED')).toBe('—') @@ -725,8 +702,20 @@ describe('the import cancel refuses through commander, not just in the contract' vi.spyOn(console, 'log').mockImplementation(() => {}) }) - it('refuses an import cancellation without --yes and sends nothing', async () => { - await expect(runLeaf(['tables', 'imports', 'cancel', 'imp-1'])).rejects.toThrow(/--yes/) + it('refuses an import cancellation without --yes, sends nothing, and says why', async () => { + // The runner commits rows batch by batch and stops between batches, so a + // cancelled import keeps what it wrote; a `replace` has already emptied the + // table by then. The refusal is where the caller reads that, so it is + // asserted through the error commander actually raises. + const refusal = await runLeaf(['tables', 'imports', 'cancel', 'imp-1']).then( + () => '', + (error: Error) => error.message + ) + + expect(refusal).toContain('--yes') + expect(refusal).toContain('replace') + expect(refusal).toMatch(/empties the table/) + expect(refusal).not.toMatch(/not recoverable|cannot be undone/) expect(mockRequest).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a0eacca9480..c423c397926 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -371,17 +371,20 @@ export const CLI_CONTRACT: CliContract = { }, // The floors are for `logs follow`, which locks its widths on the first // batch and had nothing to measure at `-n 0`. Each is what the column's own - // rendering needs: an ISO timestamp trimmed to seconds, the longest status - // and core trigger type, a UUID run id, a duration in minutes, and a + // rendering needs beyond its header label: an ISO timestamp trimmed to + // seconds, the longest status and core trigger type, a UUID run id, and a // four-decimal cost above ten credits. `workflow` is free text with no // bound, so its floor is editorial — enough to tell two runs apart. + // `duration` carries none: a lock is never narrower than its own header, + // and `DURATION` is already the eight characters a floor would have asked + // for. columns: [ { header: 'started', path: 'startedAt', format: 'timestamp', minWidth: 19 }, { header: 'status', minWidth: 9 }, { header: 'level' }, { header: 'trigger', minWidth: 12 }, { header: 'workflow', path: 'workflow.name', minWidth: 24 }, - { header: 'duration', path: 'totalDurationMs', format: 'duration', minWidth: 8 }, + { header: 'duration', path: 'totalDurationMs', format: 'duration' }, { header: 'cost', path: 'cost.total', format: 'cost', minWidth: 8 }, { header: 'run', path: 'runId', minWidth: 36 }, ], @@ -1492,11 +1495,12 @@ export const CLI_CONTRACT: CliContract = { // Not `confirm`-gated: the whole point is to stop something that is // already going wrong, and for an ordinary in-flight run `re-run it` is a // real recovery. It is NOT one for a run paused for input — cancelling - // completes the pause, so the context `workflows runs resume` needs is gone - // with the block outputs it held, and starting over repeats every side - // effect the run already performed. Gating it is a live proposal rather - // than an oversight; it is left ungated here because `confirm` is - // all-or-nothing and cancel is the command most likely to be automated. + // flips the paused row to `cancelled`, and nothing resumes from that + // status, so the snapshot is kept but `workflows runs resume` can never + // take it again and starting over repeats every side effect the run + // already performed. Gating it is a live proposal rather than an + // oversight; it is left ungated here because `confirm` is all-or-nothing + // and cancel is the command most likely to be automated. }, resumeWorkflow: { command: 'workflows runs resume', diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index 46b5ce75fe2..06448190b74 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -308,3 +308,55 @@ describe('a truncation note', () => { ) }) }) + +describe('a truncation the response states inside its payload', () => { + /** Every note goes to stderr; stdout is asserted to be untouched by it. */ + function captureStderr(): () => string { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + return () => stderr.mock.calls.map(([chunk]) => String(chunk)).join('') + } + + it('reports a clipped file body, which the envelope says nothing about', () => { + const read = captureStderr() + const payload = { fileId: 'wf_probe', name: 'a.txt', text: 'abc', truncated: true } + + renderResult('readFileText', 'json', payload, {}, {}, { data: payload }) + + expect(read()).toContain('the server clipped this result') + expect(JSON.parse(logged.join('\n'))).toEqual(payload) + }) + + it('reports a clipped row search', () => { + const read = captureStderr() + const payload = { matches: [{ ordinal: 1, rowId: 'row_1', column: 'name' }], truncated: true } + + renderResult('searchTableRows', 'json', payload, {}, {}, { data: payload }) + + expect(read()).toContain('the server clipped this result') + }) + + it('names the tool names, not the servers, on the server list', () => { + const read = captureStderr() + + renderPage('json', [{ id: 'srv_1' }], {}, { data: [], toolNamesTruncated: true }) + + expect(read()).toContain('the server clipped the tool names it returned') + }) + + it('says nothing when a negated flag reports the answer was whole', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { notTruncated: true } }) + + expect(read()).toBe('') + }) + + it('leaves yaml a bare payload, with the note on stderr', () => { + const read = captureStderr() + + renderPage('yaml', [{ id: 'a' }], {}, { data: [], truncated: true }) + + expect(logged.join('\n')).not.toContain('clipped') + expect(read()).toContain('the server clipped this result') + }) +}) diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index d8b41f3857b..8bb27957037 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -315,22 +315,51 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { } /** - * Envelope fields that state the server itself clipped the list. + * Response fields that state the server itself clipped what it returned. * * Matched by shape rather than listed per command, so a flag added to a route * envelope is surfaced the day it lands: the CLI accumulates the rows and * prints those, so an envelope field reaches no output format on its own. */ -const TRUNCATION_FLAG = /^truncated$|Truncated$/ +const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ -/** The envelope flags a page raised, in the spelling the wire used. */ -function truncationFlags(envelope: unknown): string[] { - if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return [] - return Object.entries(envelope) - .filter(([key, value]) => value === true && TRUNCATION_FLAG.test(key)) +/** + * Spellings whose `Truncated` suffix is negated, and so state the opposite. + * + * A bare `Truncated$` match also accepts `notTruncated` and `isNotTruncated`, + * where `true` means the answer is whole — the one thing this note must never + * turn into is a warning about a clip that did not happen. + */ +const NEGATED_TRUNCATION_FLAG = /^(?:not|un)Truncated$|(?:Not|Un)Truncated$/ + +/** The flags one object raised, in the spelling the wire used. */ +function truncationFlags(container: unknown): string[] { + if (!container || typeof container !== 'object' || Array.isArray(container)) return [] + return Object.entries(container) + .filter( + ([key, value]) => + value === true && TRUNCATION_FLAG.test(key) && !NEGATED_TRUNCATION_FLAG.test(key) + ) .map(([key]) => key) } +/** + * The flags a whole response raised, on its envelope or inside its payload. + * + * Two responses state their clip one level down: `files text`, whose file body + * stops at `maxBytes`, and `tables rows search`, whose match list the server + * stops building. An envelope-only scan said nothing about either — silently + * handing back a partial file is the same defect the note exists to close, on a + * worse payload than a short list. + * + * Only `data` is descended, and only while it is an object: a page's `data` is + * the rows, and a row's keys are the user's rather than the API's, so a shape + * match there is a guess about a name somebody else chose. + */ +function responseTruncationFlags(envelope: unknown): string[] { + return [...truncationFlags(envelope), ...truncationFlags(at(envelope, 'data'))] +} + /** * Carries a truncation stated on any page, not only the first. * @@ -356,6 +385,19 @@ function spellOut(flag: string): string { .trim() } +/** + * What a flag says was clipped, read from the words before its suffix. + * + * `toolNamesTruncated` on `workflow-mcp-servers list` is a clip of each row's + * tool names, not of the servers, so one wording about "this list" named the + * wrong thing on the one endpoint whose subject is not the list. A bare + * `truncated` carries no subject and stands for the whole answer. + */ +function clippedSubject(flag: string): string { + const subject = flag.replace(/^truncated$|Truncated$/, '') + return subject ? `the ${spellOut(subject)} it returned` : 'this result' +} + /** * States a server-side clip once, on stderr, in every format. * @@ -365,9 +407,11 @@ function spellOut(flag: string): string { * `writePageNote` gives. */ function writeEnvelopeTruncation(envelope: unknown): void { - for (const flag of truncationFlags(envelope)) { + for (const flag of responseTruncationFlags(envelope)) { process.stderr.write( - chalk.dim(`${spellOut(flag)}: the server clipped this list, so it is incomplete\n`) + chalk.dim( + `${spellOut(flag)}: the server clipped ${clippedSubject(flag)}, so the answer is incomplete\n` + ) ) } } From 2f85b5f9b7c52081c4e346ca01f47467a3a3e82d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:51:06 -0700 Subject: [PATCH 12/15] chore: regenerate the API reference, CLI surface, and CLI docs The published reference still marked a secret value required and described the delete parameter as one that also creates, the CLI surface still lacked the marker that says which operations refuse a workspace key, and the reference rendered an empty sentence for every repeatable filter whose default is an empty list. --- apps/docs/content/docs/en/cli/audit-logs.mdx | 4 + apps/docs/content/docs/en/cli/billing.mdx | 2 + apps/docs/content/docs/en/cli/credentials.mdx | 4 + apps/docs/content/docs/en/cli/files.mdx | 14 +- apps/docs/content/docs/en/cli/knowledge.mdx | 58 ++++- apps/docs/content/docs/en/cli/logs.mdx | 18 +- apps/docs/content/docs/en/cli/mcp-servers.mdx | 2 + apps/docs/content/docs/en/cli/reference.mdx | 203 +++++++++--------- apps/docs/content/docs/en/cli/secrets.mdx | 6 +- apps/docs/content/docs/en/cli/skills.mdx | 10 + apps/docs/content/docs/en/cli/tables.mdx | 39 ++-- .../docs/en/cli/workflow-mcp-servers.mdx | 18 +- apps/docs/content/docs/en/cli/workflows.mdx | 28 ++- apps/docs/openapi-v2-files-audit.json | 26 +-- apps/docs/openapi-v2-knowledge.json | 15 +- apps/docs/openapi-v2-logs.json | 4 +- apps/docs/openapi-v2-resources.json | 39 ++-- apps/docs/openapi-v2-tables.json | 10 +- apps/docs/openapi-v2-workflows.json | 4 +- packages/sim-cli/src/generated/v2-api.ts | 2 +- 20 files changed, 324 insertions(+), 182 deletions(-) diff --git a/apps/docs/content/docs/en/cli/audit-logs.mdx b/apps/docs/content/docs/en/cli/audit-logs.mdx index 01104feceb9..1c06a2785ee 100644 --- a/apps/docs/content/docs/en/cli/audit-logs.mdx +++ b/apps/docs/content/docs/en/cli/audit-logs.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` +Get Audit Log (personal API key required) + **Arguments** @@ -41,6 +43,8 @@ sim audit-logs get [options] sim audit-logs list [options] ``` +List Audit Logs (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index c93a0552c2e..b97e19c8156 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,6 +31,8 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) + **Options** diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index de77e662048..6aa4143baed 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` +Disconnect Credential (personal API key required) + **Arguments** @@ -78,6 +80,8 @@ sim credentials list [options] sim credentials update [options] ``` +Update Credential (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index f910224e817..40758bc2999 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -21,7 +21,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -107,7 +107,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -194,7 +194,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -220,6 +220,8 @@ sim files share get sim files share set [options] ``` +Enable or disable sharing for a file (personal API key required) + **Arguments** @@ -239,7 +241,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -258,7 +260,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -280,7 +282,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 9981ebce825..a760b5067b1 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` +Index files the workspace already stores (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -41,6 +43,8 @@ sim knowledge from-workspace-files create [options] sim knowledge tags save [options] ``` +Declare the tag definitions a knowledge base needs (personal API key required) + **Arguments** @@ -67,6 +71,8 @@ sim knowledge tags save [options] sim knowledge tags create [options] ``` +Create Tag (personal API key required) + **Arguments** @@ -95,6 +101,8 @@ sim knowledge tags create [options] sim knowledge tags delete [options] ``` +Delete Tag (personal API key required) + **Arguments** @@ -122,6 +130,8 @@ sim knowledge tags delete [options] sim knowledge tags cleanup [options] ``` +Remove tag definitions no document still uses (personal API key required) + **Arguments** @@ -150,6 +160,8 @@ sim knowledge tags cleanup [options] sim knowledge tags next-slot [options] ``` +Show which tag slot a create would take for a field type (personal API key required) + **Arguments** @@ -192,6 +204,8 @@ sim knowledge tags list sim knowledge tags usage ``` +Show how many documents and chunks carry each tag (personal API key required) + **Arguments** @@ -208,6 +222,8 @@ sim knowledge tags usage sim knowledge tags update [options] ``` +Update Tag (personal API key required) + **Arguments** @@ -236,6 +252,8 @@ sim knowledge tags update [options] sim knowledge chunks batch-update [options] ``` +Enable, disable, or delete many chunks at once (personal API key required) + **Arguments** @@ -254,7 +272,7 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -265,6 +283,8 @@ sim knowledge chunks batch-update [options] sim knowledge chunks create [options] ``` +Create Chunk (personal API key required) + **Arguments** @@ -294,6 +314,8 @@ sim knowledge chunks create [options] sim knowledge chunks delete [options] ``` +Delete Chunk (personal API key required) + **Arguments** @@ -322,6 +344,8 @@ sim knowledge chunks delete [options] sim knowledge chunks get ``` +Get Chunk (personal API key required) + **Arguments** @@ -340,6 +364,8 @@ sim knowledge chunks get sim knowledge chunks list [options] ``` +List Chunks (personal API key required) + **Arguments** @@ -371,6 +397,8 @@ sim knowledge chunks list [options] sim knowledge chunks update [options] ``` +Update Chunk (personal API key required) + **Arguments** @@ -401,6 +429,8 @@ sim knowledge chunks update [options] sim knowledge documents batch-update [options] ``` +Enable or disable every matching document (personal API key required) + **Arguments** @@ -418,7 +448,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -505,6 +535,8 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` +Update Document (personal API key required) + **Arguments** @@ -604,6 +636,8 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` +Create Knowledge Connector (personal API key required) + **Arguments** @@ -634,6 +668,8 @@ sim knowledge connectors create [options] sim knowledge connectors delete [options] ``` +Delete Knowledge Connector (personal API key required) + **Arguments** @@ -663,6 +699,8 @@ sim knowledge connectors delete [options] sim knowledge connectors get ``` +Get Knowledge Connector (personal API key required) + **Arguments** @@ -680,6 +718,8 @@ sim knowledge connectors get sim knowledge connectors documents list [options] ``` +List Knowledge Connector Documents (personal API key required) + **Arguments** @@ -709,6 +749,8 @@ sim knowledge connectors documents list [options sim knowledge connectors documents update [options] ``` +Update Knowledge Connector Documents (personal API key required) + **Arguments** @@ -727,7 +769,7 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -737,6 +779,8 @@ sim knowledge connectors documents update [optio sim knowledge connectors list [options] ``` +List Knowledge Connectors (personal API key required) + **Arguments** @@ -765,6 +809,8 @@ sim knowledge connectors list [options] sim knowledge connectors sync [options] ``` +Queue a knowledge connector synchronization (personal API key required) + **Arguments** @@ -793,6 +839,8 @@ sim knowledge connectors sync [options] sim knowledge connectors update [options] ``` +Update Knowledge Connector (personal API key required) + **Arguments** @@ -990,7 +1038,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 920792e8bdf..c5b35b9d216 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -47,9 +47,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -69,8 +69,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -90,7 +90,7 @@ sim logs list [options] | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -106,9 +106,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index c099de5b6a9..1eac319876c 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -103,6 +103,8 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` +List MCP Server Tools (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a2db7d27cbf..a0b890e97e8 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -175,7 +175,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log +Get Audit Log (personal API key required) ```bash sim audit-logs get [options] @@ -203,7 +203,7 @@ sim audit-logs get [options] ### sim audit-logs list -List Audit Logs +List Audit Logs (personal API key required) ```bash sim audit-logs list [options] @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's) ```bash sim billing logs [options] @@ -367,7 +367,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential +Disconnect Credential (personal API key required) ```bash sim credentials delete [options] @@ -436,7 +436,7 @@ sim credentials list [options] ### sim credentials update -Update Credential +Update Credential (personal API key required) ```bash sim credentials update [options] @@ -693,7 +693,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -787,7 +787,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -882,7 +882,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -906,7 +906,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file +Enable or disable sharing for a file (personal API key required) ```bash sim files share set [options] @@ -931,7 +931,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -952,7 +952,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -976,7 +976,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1223,7 +1223,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores +Index files the workspace already stores (personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1245,13 +1245,13 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge tags save -Declare the tag definitions a knowledge base needs +Declare the tag definitions a knowledge base needs (personal API key required) ```bash sim knowledge tags save [options] @@ -1279,7 +1279,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag +Create Tag (personal API key required) ```bash sim knowledge tags create [options] @@ -1309,7 +1309,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag +Delete Tag (personal API key required) ```bash sim knowledge tags delete [options] @@ -1338,7 +1338,7 @@ sim knowledge tags delete [options] ### sim knowledge tags cleanup -Remove tag definitions no document still uses +Remove tag definitions no document still uses (personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1368,7 +1368,7 @@ sim knowledge tags cleanup [options] ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type +Show which tag slot a create would take for a field type (personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1414,7 +1414,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag +Show how many documents and chunks carry each tag (personal API key required) ```bash sim knowledge tags usage @@ -1432,7 +1432,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag +Update Tag (personal API key required) ```bash sim knowledge tags update [options] @@ -1462,7 +1462,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once +Enable, disable, or delete many chunks at once (personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1486,14 +1486,14 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | ### sim knowledge chunks create -Create Chunk +Create Chunk (personal API key required) ```bash sim knowledge chunks create [options] @@ -1524,7 +1524,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk +Delete Chunk (personal API key required) ```bash sim knowledge chunks delete [options] @@ -1554,7 +1554,7 @@ sim knowledge chunks delete [options] ### sim knowledge chunks get -Get Chunk +Get Chunk (personal API key required) ```bash sim knowledge chunks get @@ -1574,7 +1574,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks +List Chunks (personal API key required) ```bash sim knowledge chunks list [options] @@ -1607,7 +1607,7 @@ sim knowledge chunks list [options] ### sim knowledge chunks update -Update Chunk +Update Chunk (personal API key required) ```bash sim knowledge chunks update [options] @@ -1639,7 +1639,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document +Enable or disable every matching document (personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1662,7 +1662,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -1751,7 +1751,7 @@ sim knowledge documents list [options] ### sim knowledge documents update -Update Document +Update Document (personal API key required) ```bash sim knowledge documents update [options] @@ -1856,7 +1856,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector +Create Knowledge Connector (personal API key required) ```bash sim knowledge connectors create [options] @@ -1888,7 +1888,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector +Delete Knowledge Connector (personal API key required) ```bash sim knowledge connectors delete [options] @@ -1919,7 +1919,7 @@ sim knowledge connectors delete [options] ### sim knowledge connectors get -Get Knowledge Connector +Get Knowledge Connector (personal API key required) ```bash sim knowledge connectors get @@ -1938,7 +1938,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents +List Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents list [options] @@ -1969,7 +1969,7 @@ sim knowledge connectors documents list [options ### sim knowledge connectors documents update -Update Knowledge Connector Documents +Update Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents update [options] @@ -1993,13 +1993,13 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge connectors list -List Knowledge Connectors +List Knowledge Connectors (personal API key required) ```bash sim knowledge connectors list [options] @@ -2029,7 +2029,7 @@ sim knowledge connectors list [options] ### sim knowledge connectors sync -Queue a knowledge connector synchronization +Queue a knowledge connector synchronization (personal API key required) ```bash sim knowledge connectors sync [options] @@ -2059,7 +2059,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector +Update Knowledge Connector (personal API key required) ```bash sim knowledge connectors update [options] @@ -2280,7 +2280,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | @@ -2435,9 +2435,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2459,8 +2459,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2480,7 +2480,7 @@ sim logs list [options] | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -2498,9 +2498,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | @@ -2610,7 +2610,7 @@ sim mcp-servers list [options] ### sim mcp-servers tools list -List MCP Server Tools +List MCP Server Tools (personal API key required) ```bash sim mcp-servers tools list [options] @@ -2692,7 +2692,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret +Delete Secret (personal API key required) ```bash sim secrets delete [options] @@ -2704,7 +2704,7 @@ sim secrets delete [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -2721,7 +2721,7 @@ sim secrets delete [options] ### sim secrets list -List Secrets +List Secrets (personal API key required) ```bash sim secrets list [options] @@ -2779,7 +2779,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill +Create Skill (personal API key required) ```bash sim skills create [options] @@ -2799,7 +2799,7 @@ sim skills create [options] ### sim skills delete -Delete Skill +Delete Skill (personal API key required) ```bash sim skills delete [options] @@ -2845,7 +2845,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor +Grant Skill Editor (personal API key required) ```bash sim skills editors create [options] @@ -2903,7 +2903,7 @@ sim skills editors list [options] ### sim skills editors delete -Revoke Skill Editor +Revoke Skill Editor (personal API key required) ```bash sim skills editors delete [options] @@ -2953,7 +2953,7 @@ sim skills list [options] ### sim skills update -Update Skill +Update Skill (personal API key required) ```bash sim skills update [options] @@ -3203,7 +3203,7 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3319,8 +3319,8 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3521,7 +3521,7 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -3608,11 +3608,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -3656,7 +3656,7 @@ sim tables dispatches list ### sim tables exports cancel -Cancel Table Export +Stop a running export ```bash sim tables exports cancel @@ -3741,10 +3741,10 @@ sim tables exports download ### sim tables imports cancel -Cancel Table Import +Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -3757,6 +3757,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ### sim tables imports get Get Table Import @@ -3802,7 +3812,7 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -4145,7 +4155,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -4169,7 +4179,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -4265,7 +4275,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -4302,6 +4312,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | @@ -4400,7 +4411,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server +Create Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4416,13 +4427,13 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim workflow-mcp-servers delete -Delete Workflow MCP Server +Delete Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4450,7 +4461,7 @@ sim workflow-mcp-servers delete [options] ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool +Publish Workflow As MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4481,7 +4492,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools +List Workflow MCP Tools (personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4499,7 +4510,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool +Unpublish Workflow MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4528,7 +4539,7 @@ sim workflow-mcp-servers tools delete [options] ### sim workflow-mcp-servers get -Get Workflow MCP Server +Get Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers get @@ -4546,7 +4557,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers +List Workflow MCP Servers (personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4566,7 +4577,7 @@ sim workflow-mcp-servers list [options] ### sim workflow-mcp-servers update -Update Workflow MCP Server +Update Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4601,7 +4612,7 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version +Activate Workflow Version (personal API key required) ```bash sim workflows activate create @@ -4620,7 +4631,7 @@ sim workflows activate create ### sim workflows operations apply -Apply Workflow Operations +Apply Workflow Operations (personal API key required) ```bash sim workflows operations apply [options] @@ -4736,7 +4747,7 @@ sim workflows runs get [options] | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -4967,7 +4978,7 @@ sim workflows delete [options] ### sim workflows chat unpublish -Take a workflow’s chat deployment offline +Take a workflow’s chat deployment offline (personal API key required) ```bash sim workflows chat unpublish [options] @@ -4995,7 +5006,7 @@ sim workflows chat unpublish [options] ### sim workflows chat status -Show a workflow’s chat deployment +Show a workflow’s chat deployment (personal API key required) ```bash sim workflows chat status @@ -5013,7 +5024,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment +Publish or replace a workflow’s chat deployment (personal API key required) ```bash sim workflows chat publish [options] @@ -5053,7 +5064,7 @@ sim workflows chat publish [options] ### sim workflows deploy -Deploy Workflow +Deploy Workflow (personal API key required) ```bash sim workflows deploy [options] @@ -5136,7 +5147,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -5208,7 +5219,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access +Update Workflow Public API Access (personal API key required) ```bash sim workflows deployment update [options] @@ -5254,7 +5265,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State +Replace Workflow State (personal API key required) ```bash sim workflows state replace [options] @@ -5424,7 +5435,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -5449,7 +5460,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version +Revert Workflow To Version (personal API key required) ```bash sim workflows revert create [options] @@ -5478,7 +5489,7 @@ sim workflows revert create [options] ### sim workflows rollback -Rollback Workflow +Rollback Workflow (personal API key required) ```bash sim workflows rollback [options] @@ -5507,7 +5518,7 @@ sim workflows rollback [options] ### sim workflows undeploy -Take a workflow out of deployment +Take a workflow out of deployment (personal API key required) ```bash sim workflows undeploy [options] diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index 9971d7eb1ab..cde61e9b084 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -15,13 +15,15 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` +Delete Secret (personal API key required) + **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -42,6 +44,8 @@ sim secrets delete [options] sim secrets list [options] ``` +List Secrets (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index e6ffe5dc906..a9f969b869b 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` +Create Skill (personal API key required) + **Options** @@ -33,6 +35,8 @@ sim skills create [options] sim skills delete [options] ``` +Delete Skill (personal API key required) + **Arguments** @@ -75,6 +79,8 @@ sim skills get sim skills editors create [options] ``` +Grant Skill Editor (personal API key required) + **Arguments** @@ -129,6 +135,8 @@ sim skills editors list [options] sim skills editors delete [options] ``` +Revoke Skill Editor (personal API key required) + **Arguments** @@ -175,6 +183,8 @@ sim skills list [options] sim skills update [options] ``` +Update Skill (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index a3804a0beab..1739a5ef1e8 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -211,7 +211,7 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -319,8 +319,8 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -507,7 +507,7 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -588,11 +588,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -630,7 +630,7 @@ sim tables dispatches list -## Cancel table export +## Stop a running export ```bash sim tables exports cancel @@ -707,10 +707,10 @@ sim tables exports download -## Cancel table import +## Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -723,6 +723,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this destructive operation. | + + + ## Get table import ```bash @@ -764,7 +774,7 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this destructive operation. | @@ -1077,7 +1087,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -1099,7 +1109,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1187,7 +1197,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -1222,6 +1232,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx index 62a3528f07c..af1ff6add1b 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -13,6 +13,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` +Create Workflow MCP Server (personal API key required) + **Options** @@ -23,7 +25,7 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -33,6 +35,8 @@ sim workflow-mcp-servers create [options] sim workflow-mcp-servers delete [options] ``` +Delete Workflow MCP Server (personal API key required) + **Arguments** @@ -59,6 +63,8 @@ sim workflow-mcp-servers delete [options] sim workflow-mcp-servers tools create [options] ``` +Publish Workflow As MCP Tool (personal API key required) + **Arguments** @@ -88,6 +94,8 @@ sim workflow-mcp-servers tools create [options] sim workflow-mcp-servers tools list ``` +List Workflow MCP Tools (personal API key required) + **Arguments** @@ -104,6 +112,8 @@ sim workflow-mcp-servers tools list sim workflow-mcp-servers tools delete [options] ``` +Unpublish Workflow MCP Tool (personal API key required) + **Arguments** @@ -131,6 +141,8 @@ sim workflow-mcp-servers tools delete [options] sim workflow-mcp-servers get ``` +Get Workflow MCP Server (personal API key required) + **Arguments** @@ -147,6 +159,8 @@ sim workflow-mcp-servers get sim workflow-mcp-servers list [options] ``` +List Workflow MCP Servers (personal API key required) + **Options** @@ -165,6 +179,8 @@ sim workflow-mcp-servers list [options] sim workflow-mcp-servers update [options] ``` +Update Workflow MCP Server (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 648f28967e6..f622a8b7669 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflows activate create ``` +Activate Workflow Version (personal API key required) + **Arguments** @@ -32,6 +34,8 @@ sim workflows activate create sim workflows operations apply [options] ``` +Apply Workflow Operations (personal API key required) + **Arguments** @@ -138,7 +142,7 @@ Show run status (requested outputs are included in JSON or YAML output) | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -357,6 +361,8 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` +Take a workflow’s chat deployment offline (personal API key required) + **Arguments** @@ -383,6 +389,8 @@ sim workflows chat unpublish [options] sim workflows chat status ``` +Show a workflow’s chat deployment (personal API key required) + **Arguments** @@ -399,6 +407,8 @@ sim workflows chat status sim workflows chat publish [options] ``` +Publish or replace a workflow’s chat deployment (personal API key required) + **Arguments** @@ -437,6 +447,8 @@ sim workflows chat publish [options] sim workflows deploy [options] ``` +Deploy Workflow (personal API key required) + **Arguments** @@ -510,7 +522,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -580,6 +592,8 @@ sim workflows deployment status sim workflows deployment update [options] ``` +Update Workflow Public API Access (personal API key required) + **Arguments** @@ -622,6 +636,8 @@ sim workflows state get sim workflows state replace [options] ``` +Replace Workflow State (personal API key required) + **Arguments** @@ -774,7 +790,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -801,6 +817,8 @@ sim workflows restore sim workflows revert create [options] ``` +Revert Workflow To Version (personal API key required) + **Arguments** @@ -828,6 +846,8 @@ sim workflows revert create [options] sim workflows rollback [options] ``` +Rollback Workflow (personal API key required) + **Arguments** @@ -855,6 +875,8 @@ sim workflows rollback [options] sim workflows undeploy [options] ``` +Take a workflow out of deployment (personal API key required) + **Arguments** diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 449217a46b5..88dd604ab00 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -93,10 +93,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -1384,10 +1384,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2144,10 +2144,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2841,12 +2841,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -2888,7 +2888,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] } @@ -3673,12 +3673,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -3720,7 +3720,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] }, @@ -4430,7 +4430,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.", + "description": "Path of the archived folder to restore, as reported by an archived-scope folder list.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 8bb1c1edfaa..463a8a238c9 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6838,14 +6838,15 @@ "type": "object", "properties": { "recipe": { - "description": "Optional document processing recipe.", + "description": "Optional document processing recipe. One of: default, plain, markdown, code.", "type": "string", - "maxLength": 255 + "enum": ["default", "plain", "markdown", "code"] }, "lang": { - "description": "Optional document language code.", + "description": "Optional document language, as a BCP-47 tag such as `en` or `en-US`.", "type": "string", - "maxLength": 35 + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" } }, "additionalProperties": false @@ -7876,7 +7877,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of chunks the operation changed.", + "description": "Number of chunks in this document the operation matched. Chunks already in the requested state are counted too, so this is not a count of changes.", "examples": [12] }, "errors": { @@ -7884,7 +7885,7 @@ "items": { "type": "string" }, - "description": "Per-chunk failures. A populated array still answers 200." + "description": "Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200." } }, "required": ["operation", "processed", "errors"], @@ -7927,7 +7928,7 @@ "type": "string", "minLength": 1 }, - "description": "Chunks to operate on, by identifier. Ids outside the document are ignored." + "description": "Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request." } }, "required": ["workspaceId", "operation", "chunkIds"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 08535cf3ef1..3295388729d 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -421,7 +421,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -1779,7 +1779,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours." + "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index bbc64e30435..402e5d1dfdd 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2715,26 +2715,26 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to create or replace.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to create or replace." } } ], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "content": { "application/json": { "schema": { @@ -2745,7 +2745,7 @@ }, "responses": { "200": { - "description": "The existing secret value was replaced.", + "description": "The existing secret value was replaced, or its metadata was updated in place.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2825,13 +2825,13 @@ "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to delete.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to delete." } }, { @@ -7164,11 +7164,11 @@ "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." }, "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "maxLength": 65536 }, "description": { "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", @@ -7187,15 +7187,20 @@ "type": "boolean" } }, - "required": ["workspaceId", "scope", "value"], + "required": ["workspaceId", "scope"], "additionalProperties": false, "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "scope": "workspace", "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false } ] }, @@ -7393,7 +7398,7 @@ }, "toolNamesTruncated": { "type": "boolean", - "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." } }, "required": ["data", "nextCursor", "toolNamesTruncated"], @@ -8189,14 +8194,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", @@ -8877,14 +8882,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 52a8d00198b..25250fc7714 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,10 +55,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -6400,7 +6400,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -7628,7 +7628,7 @@ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "group": { - "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Enrich company", "outputs": [ { @@ -9658,7 +9658,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path the folder held when `DELETE /api/v2/tables/folders` archived it.", + "description": "Path the folder held when a folder delete archived it.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 13416eb4837..0ce787f8c71 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -338,7 +338,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +437,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the same `409` when an id is already owned by another workflow. `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are not reported.", "tags": ["Workflows"], "parameters": [ { diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 1aad0d91e30..ff357faaa41 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -11771,7 +11771,7 @@ export const V2_OPERATIONS = { values: ['active', 'archived'] as const, default: 'active', describe: - 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', }, search: { kind: 'string', From cb40f3c27801c093a810041d0bcd642f1d13ea5f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 13:52:22 -0700 Subject: [PATCH 13/15] test(cli): use the package's own delay helper in the staging poll The audit bans a hand-rolled setTimeout promise. `sim-cli` does not depend on the shared utils package, and its own idiom is `node:timers/promises`. --- packages/sim-cli/src/commands/protocol/files-get.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index d6d4041bb3f..3ae61611817 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { Writable } from 'node:stream' +import { setTimeout as sleep } from 'node:timers/promises' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildGeneratedCommands } from '../../runtime/build' @@ -166,7 +167,7 @@ describe('an interrupted download', () => { for (let attempt = 0; attempt < 2000; attempt += 1) { const [staged] = stagingDirectories() if (staged) return join(dir, staged) - await new Promise((resolve) => setTimeout(resolve, 1)) + await sleep(1) } throw new Error('the download staged no directory') } From 29b2d8b289228baa73f8a6b0032269952ddde341 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 14:31:36 -0700 Subject: [PATCH 14/15] fix: act on a second review round, and correct two earlier claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict pre-check read block ids from the wrong side. The writer inserts each block's own `id` field while the check read the record key, and the two can diverge because preparation copies a value under its key without reconciling them. Edges already read the value and subflows are genuinely keyed by the record key, so only blocks were wrong — collecting every family from the values, as first suggested, would have broken subflows instead. A minted API key carrying leading or trailing whitespace passed the pre-write check but failed the writer, leaving the new endpoint on disk beside the previous key. It is refused up front now rather than trimmed: a key is opaque, so trimming would store a value the server never issued and turn a loud failure into an unexplained 401 later. The endpoint normalizer does trim, which is what made a padded `--endpoint` fail only after the browser flow had already minted a key. A metadata-only secret write raced with deletion returned 500, because the follow-up read that only assembles the response body threw an unclassified error; it now reports the same not-found the non-racing miss already gave. An unusable output format in the environment silently printed a table instead of refusing. Two validation messages printed control characters verbatim. A dry run now reports the preparation warnings its own commit path returns. The chat route created a titled conversation and never wrote a message, so it appeared in the Chat list promising content it did not have. Both sides of a successful turn are now persisted; a failed turn still writes nothing, so a question is never stored without its answer. Two claims of mine were wrong. The earlier commit message said `secrets set` sent an empty value that overwrote the stored secret — it did not; the prompt refuses off a TTY and rejects empty on one, so the old behaviour was a clean refusal. And the delay helper commit said this package's idiom is `node:timers/promises`; the package carries its own `sleep`, which is the audit's sanctioned home and has five callers. It uses that now. Also: a test asserting a deadlock stays unclassified could not fail, since every candidate rejects it; it now pins a unique violation carrying no constraint name. Workflow ids spelled with the file prefix are corrected in the remaining fixtures, leaving the genuine file ids alone. --- apps/docs/content/docs/en/cli/scripting.mdx | 8 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 4 +- apps/docs/openapi-v2-workflows.json | 2 +- apps/sim/app/api/v2/chat/route.test.ts | 91 +++++++- apps/sim/app/api/v2/chat/route.ts | 41 +++- .../skills/[skillId]/skill-detail.tsx | 2 +- .../api/contracts/knowledge/documents.test.ts | 17 +- apps/sim/lib/api/contracts/v2/logs-stats.ts | 2 +- .../contracts/v2/openapi/knowledge-chunks.ts | 2 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 2 +- .../lib/api/contracts/v2/openapi/workflows.ts | 2 +- apps/sim/lib/copilot/chat/messages-store.ts | 29 ++- apps/sim/lib/copilot/constants.ts | 12 +- .../sim/lib/knowledge/upload-metadata.test.ts | 24 ++- apps/sim/lib/knowledge/upload-metadata.ts | 18 +- .../sim/lib/mothership/inbox/executor.test.ts | 28 ++- apps/sim/lib/mothership/inbox/executor.ts | 3 +- .../lib/secrets/application/use-cases.test.ts | 20 ++ apps/sim/lib/secrets/application/use-cases.ts | 10 +- .../application/editor-use-cases.test.ts | 53 ++++- apps/sim/lib/skills/application/use-cases.ts | 13 +- apps/sim/lib/table/application/tables.ts | 5 +- .../apply-workflow-operations.test.ts | 30 +++ .../application/apply-workflow-operations.ts | 26 ++- .../replace-normalized-state.test.ts | 49 ++++- .../persistence/replace-normalized-state.ts | 10 +- packages/sim-cli/src/commands/auth.test.ts | 50 +++++ packages/sim-cli/src/commands/auth.ts | 30 ++- .../src/commands/protocol/files-get.test.ts | 2 +- .../protocol/workflow-run-follow.test.ts | 63 ++++-- .../protocol/workflow-run-wait.test.ts | 22 +- packages/sim-cli/src/config/ini.ts | 17 +- packages/sim-cli/src/config/profile.test.ts | 29 +++ packages/sim-cli/src/config/profile.ts | 19 +- packages/sim-cli/src/runtime/build.test.ts | 195 +++++++++++++----- packages/sim-cli/src/runtime/request.test.ts | 37 ++++ packages/sim-cli/src/runtime/request.ts | 45 ++-- packages/sim-cli/src/runtime/result.test.ts | 56 +++++ packages/sim-cli/src/runtime/result.ts | 10 +- 40 files changed, 921 insertions(+), 159 deletions(-) diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index c1f38b95589..af4dc93cce9 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -89,9 +89,11 @@ sim files delete wf_8Kd2NpVrY6zTfQa3XwBmS --yes Without `--yes` the command explains what it would have destroyed and stops. -`batch-delete` and `batch-update` carry the default `--limit` of `100`, so a -filter matching more rows than that silently affects only the first 100. Pass -`--limit 0` to affect every matching row. +On `batch-delete` and `batch-update`, `--limit` has no default and is not a page +size — it is a ceiling on how many matching rows the one call may touch. Leave it +off and the command acts on **every** row the filter matches, however many that +is. `--limit 0` is not the unbounded form here and is rejected; pass a whole +number of 1 or more to cap the blast radius, or omit the flag deliberately. ## Exit codes diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 463a8a238c9..c9c1409603e 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -3481,7 +3481,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so `processed` is the authoritative count. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 3295388729d..1c723026223 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -421,7 +421,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -1779,7 +1779,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." + "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 0ce787f8c71..eaee255cf16 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -437,7 +437,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the same `409` when an id is already owned by another workflow. `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are not reported.", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 97ace3a9819..823e81299e0 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -14,6 +14,7 @@ const { mockCheckOperationRate, mockCheckPreAuthRate, mockGenerateId, + mockPersistCopilotChatTurn, mockRequestExplicitStreamAbort, mockResolveBillingAttribution, mockResolveOrCreateChat, @@ -35,6 +36,7 @@ const { mockCheckOperationRate: vi.fn(), mockCheckPreAuthRate: vi.fn(), mockGenerateId: vi.fn(), + mockPersistCopilotChatTurn: vi.fn(), mockResolveBillingAttribution: vi.fn(), mockResolveOrCreateChat: vi.fn(), mockRequestExplicitStreamAbort: vi.fn().mockResolvedValue(undefined), @@ -84,6 +86,10 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ resolveOrCreateChat: mockResolveOrCreateChat, })) +vi.mock('@/lib/copilot/chat/messages-store', () => ({ + persistCopilotChatTurn: mockPersistCopilotChatTurn, +})) + vi.mock('@/lib/copilot/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]), })) @@ -131,6 +137,7 @@ function chatRow(id: string) { const successResult = { success: true, content: 'Hello there', + contentBlocks: [], toolCalls: [{ name: 'run_workflow' }, { name: 'internal_only' }], usage: { prompt: 10, completion: 5 }, cost: { total: 0.01 }, @@ -160,6 +167,7 @@ describe('POST /api/v2/chat', () => { mockAssertActiveWorkspaceAccess.mockResolvedValue({ permission: 'admin' }) mockResolveBillingAttribution.mockResolvedValue(billingAttributionSnapshot) mockRequestExplicitStreamAbort.mockResolvedValue(undefined) + mockPersistCopilotChatTurn.mockResolvedValue(undefined) mockRunHeadlessCopilotLifecycle.mockResolvedValue(successResult) mockResolveOrCreateChat.mockResolvedValue({ chatId: SERVER_ISSUED_CHAT_ID, @@ -297,6 +305,33 @@ describe('POST /api/v2/chat', () => { }) }) + it('posts only the current turn on a resumed conversation, never the stored transcript', async () => { + mockResolveOrCreateChat.mockResolvedValue({ + chatId: OWNED_CONVERSATION_ID, + chat: chatRow(OWNED_CONVERSATION_ID), + conversationHistory: [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'first reply' }, + ], + isNew: false, + }) + + const response = await callChat({ + workspaceId: 'workspace-1', + message: 'and then?', + conversationId: OWNED_CONVERSATION_ID, + }) + + expect(response.status).toBe(200) + // Continuity is keyed by chatId downstream, exactly as the web send path + // and the Sim Chat block do. Replaying the transcript here would duplicate + // every prior turn. + expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ + messages: [{ role: 'user', content: 'and then?' }], + chatId: OWNED_CONVERSATION_ID, + }) + }) + it('answers 404 and runs nothing when the resolver refuses the named conversation', async () => { mockResolveOrCreateChat.mockResolvedValue({ chatId: OWNED_CONVERSATION_ID, @@ -419,7 +454,11 @@ describe('POST /api/v2/chat', () => { }) it('ends the NDJSON stream with an error event when the run fails', async () => { - mockRunHeadlessCopilotLifecycle.mockResolvedValue({ success: false, error: 'model exploded' }) + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: false, + error: 'model exploded', + contentBlocks: [], + }) const response = await callChat( { workspaceId: 'workspace-1', message: 'hi' }, @@ -432,4 +471,54 @@ describe('POST /api/v2/chat', () => { expect(last.type).toBe('error') expect(last.error).toBe('model exploded') }) + + it('persists both sides of the turn so the conversation is not an empty transcript', async () => { + await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + const [chatId, messages] = mockPersistCopilotChatTurn.mock.calls[0] + expect(chatId).toBe(SERVER_ISSUED_CHAT_ID) + expect(messages.map((m: { role: string; content: string }) => [m.role, m.content])).toEqual([ + ['user', 'hi'], + ['assistant', 'Hello there'], + ]) + }) + + it('persists the turn on the NDJSON path too, before the final event', async () => { + const response = await callChat( + { workspaceId: 'workspace-1', message: 'hi' }, + { accept: 'application/x-ndjson' } + ) + const events = await readNdjsonEvents(response) + + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + expect(mockPersistCopilotChatTurn.mock.calls[0][1]).toHaveLength(2) + expect(events.at(-1)?.type).toBe('final') + }) + + it('persists nothing when the run fails, so no question is stored without its answer', async () => { + mockRunHeadlessCopilotLifecycle.mockResolvedValue({ + success: false, + error: 'model exploded', + contentBlocks: [], + }) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(500) + expect(mockPersistCopilotChatTurn).not.toHaveBeenCalled() + }) + + it('still answers the caller when persisting the transcript fails', async () => { + mockPersistCopilotChatTurn.mockRejectedValue(new Error('transcript write failed')) + + const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.data).toMatchObject({ + content: 'Hello there', + conversationId: SERVER_ISSUED_CHAT_ID, + }) + }) }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index a1e59cd8705..4a30f2ec7be 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -15,7 +15,12 @@ import { import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' import { chatOperations } from '@/lib/copilot/application/operations' import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' +import { persistCopilotChatTurn } from '@/lib/copilot/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, +} from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' @@ -29,7 +34,7 @@ import { } from '@/lib/copilot/generated/mothership-stream-v1' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import type { StreamEvent } from '@/lib/copilot/request/types' +import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -91,7 +96,7 @@ function encodeNdjson(value: unknown): Uint8Array { * tool calls the run surfaced. */ function buildChatResultPayload( - result: Awaited>, + result: OrchestratorResult, conversationId: string, integrationTools: Array<{ name: string }> ) { @@ -167,6 +172,9 @@ export const POST = withRouteHandler( // surface uses, and refuse every id that does not resolve with the same // response so the refusal carries no information about the id. Omitting // the id mints a server-issued conversation instead of trusting one. + // The resolved transcript is deliberately not forwarded: continuity is + // keyed by `chatId` downstream, exactly as the web send path and the Sim + // Chat block do, both of which post a single message with a chat id. const resolvedChat = await resolveOrCreateChat({ ...(conversationId ? { chatId: conversationId } : {}), userId, @@ -184,6 +192,31 @@ export const POST = withRouteHandler( } const chatId = resolvedChat.chatId reqLogger = logger.withMetadata({ chatId, messageId, requestId }) + + /** + * Write this turn's display copy to the conversation the route just + * resolved. Without it a `sim chat` turn leaves a titled conversation + * that opens to an empty transcript in the web Chat list. + * + * By the time this runs the turn has completed and been billed, and a + * streamed reply has already reached the caller, so a write failure is + * logged and the successful response still stands. The write is one + * transaction, so that failure leaves the transcript empty rather than + * showing the question without the answer. + */ + const persistTurn = async (result: OrchestratorResult): Promise => { + try { + await persistCopilotChatTurn(chatId, [ + buildPersistedUserMessage({ id: messageId, content: message }), + buildPersistedAssistantMessage(result, requestId), + ]) + } catch (error) { + reqLogger.error('Failed to persist chat transcript', { + error: getErrorMessage(error, 'Unknown error'), + }) + } + } + const secretMountPolicy = normalizeSecretMountPolicy(undefined) let environmentContext: CopilotEnvironmentContext | undefined @@ -340,6 +373,8 @@ export const POST = withRouteHandler( return } + await persistTurn(result) + send({ type: 'final', data: buildChatResultPayload(result, chatId, integrationTools), @@ -403,6 +438,8 @@ export const POST = withRouteHandler( return v2Error('INTERNAL_ERROR', result.error || 'Chat request failed') } + await persistTurn(result) + return v2Data(buildChatResultPayload(result, chatId, integrationTools)) } finally { allowExplicitAbort = false diff --git a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx index 8fe37dcfe52..dea6a7c881c 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx @@ -58,7 +58,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) { const editors = useSkillEditorsController({ skillId, workspaceId, - // Built-ins have no editors; skip the roster fetch (it would 404). + // Built-ins have no editors; skip the roster fetch (it would be refused). enabled: !!skill && !isBuiltin, }) const canEdit = !isBuiltin && !!skill?.canEdit diff --git a/apps/sim/lib/api/contracts/knowledge/documents.test.ts b/apps/sim/lib/api/contracts/knowledge/documents.test.ts index 61fbb6cc3dd..5c0510f5dc4 100644 --- a/apps/sim/lib/api/contracts/knowledge/documents.test.ts +++ b/apps/sim/lib/api/contracts/knowledge/documents.test.ts @@ -188,10 +188,25 @@ describe('internal document processingOptions', () => { expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'recipe']) }) - it('rejects a lang that is not a BCP-47 tag', () => { + it('rejects a lang outside the enforced subtag shape', () => { const result = parse({ recipe: 'default', lang: 'en_US' }) expect(result.success).toBe(false) expect(result.error?.issues[0]?.path).toEqual(['processingOptions', 'lang']) }) + + /** + * The shape these reuse is strict, so an option neither boundary implements + * is now a 400 rather than a key stripped on the way through — the behaviour + * change that reusing the upload shape brought with it. + */ + it('rejects an option key neither boundary implements rather than stripping it', () => { + const result = parse({ recipe: 'default', chunkSize: 512 }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]).toMatchObject({ + code: 'unrecognized_keys', + path: ['processingOptions'], + keys: ['chunkSize'], + }) + }) }) }) diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index 504e1792791..163150c2443 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -105,7 +105,7 @@ export const v2LogStatsSchema = z end: v2TimestampSchema.describe('ISO 8601 end of the window.'), }) .describe( - 'The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width.' + 'The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width.' ), segmentMs: z.number().describe('Width of one bucket in milliseconds.'), }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts index 7fec5395db8..9f8e603b254 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/knowledge-chunks.ts @@ -106,7 +106,7 @@ export const knowledgeChunkOpenApiRoutes = [ knowledgeOperation({ operationId: 'bulkUpdateKnowledgeChunks', summary: 'Bulk Update Chunks', - description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so \`processed\` is the authoritative count. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, + description: `Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in \`errors\` rather than failing the request. \`processed\` counts the chunks the operation matched, not the chunks it changed. ${CONNECTOR_MANAGED} ${WORKSPACE_API_KEY_DENIED}`, errors: RESOURCE_ERRORS, success: { description: 'Outcome of the bulk chunk operation.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index 9c777081ac2..6b2e6e3c2f4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -207,7 +207,7 @@ const declaredRoutes = [ logsOperation({ operationId: 'getLogStats', summary: 'Get Log Statistics', - description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs only the left edge falls back, to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding \`endDate\` when only \`endDate\` was supplied. A supplied \`startDate\` is still used verbatim, so a \`startDate\` without an \`endDate\` yields \`[startDate, now]\`, which can be any width. The window is divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding \`endDate\` when only \`endDate\` was supplied. A supplied \`startDate\` is still used verbatim, so a \`startDate\` without an \`endDate\` yields \`[startDate, now]\`, which can be any width. The window is divided into exactly \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Bucketed execution statistics for the workspace.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index a5b3bd4e765..ec23ed2c53a 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -364,7 +364,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'applyWorkflowOperations', summary: 'Apply Workflow Operations', - description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the same \`409\` when an id is already owned by another workflow. \`needsRedeployment\` describes the state before the write, and warnings raised by persistence itself are not reported.`, + description: `Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in \`skipped\`, each with a machine-readable \`type\`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. \`deferred\` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet \`atomic\` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers \`409\` with \`error.details.code: "OPERATIONS_NOT_APPLIED"\`, the same \`skipped\` array, and a \`droppedInputs\` array, having persisted nothing.\n\nA \`block_id\` you supply on an \`add\` or \`insert_into_subflow\` is only a label unless it is already a UUID: the engine mints one and returns the pairing in \`mintedBlockIds\`. References between operations in the same batch are remapped for you, so \`triage\` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation \`params\` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: \`inputs\` keyed by sub-block id, with \`retry\`, \`triggerMode\` and \`advancedMode\` beside it rather than inside it, and \`connections\` keyed by source handle. \`GET /blocks/{blockId}\` publishes the inputs a given block type accepts. The Agent block’s \`inputs.tools\` value is the important exception to that open catalog shape: it is published here as the named \`AgentToolInput\` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only \`inputValidationErrors\` lists inputs that were actually dropped.\n\nAs with \`PUT /workflows/{workflowId}/state\`, this changes only the draft; deploy to publish it. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Only \`needsRedeployment\` differs: it describes the state before the write.`, errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The batch was applied.'), }), diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts index 15b988c7998..386f8e1e11e 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { copilotMessages } from '@sim/db/schema' +import { copilotChats, copilotMessages } from '@sim/db/schema' import { and, eq, notInArray, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' @@ -78,6 +78,33 @@ export async function appendCopilotChatMessages( }) } +/** + * Persist one completed turn — the user message and the assistant reply — into + * a chat's transcript, bumping the chat's `updatedAt` so it sorts by recency. + * + * Headless callers need this because the orchestrator never writes messages: + * the interactive web surface persists them from its own client store, so a + * turn run without that surface would leave a chat that opens to nothing. + * + * Both messages are written in a single transaction, so a failure leaves the + * transcript untouched rather than showing a question with no answer. Does + * nothing when the chat no longer exists; throws on a write failure. + */ +export async function persistCopilotChatTurn( + chatId: string, + messages: PersistedMessage[] +): Promise { + await db.transaction(async (tx) => { + const [updated] = await tx + .update(copilotChats) + .set({ updatedAt: new Date() }) + .where(eq(copilotChats.id, chatId)) + .returning({ model: copilotChats.model }) + if (!updated) return + await appendCopilotChatMessages(chatId, messages, { chatModel: updated.model ?? null }, tx) + }) +} + /** * Replace all messages for a chat from a full snapshot (used by update-messages). * Throws on failure. Pass `executor` to enlist the delete+insert in an existing diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index 91f018596ac..446d9bd6ac5 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -79,8 +79,14 @@ export const COPILOT_MODES = ['ask', 'build', 'plan'] as const export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const /** - * Model stamped on a newly created mothership conversation, by the web Chat - * surface and by the `sim chat` API alike. Shared so the two creation paths - * cannot drift onto different models for the same conversation type. + * Model stamped on a mothership conversation row created outside the + * interactive send path: `POST /api/mothership/chats` (an empty chat), the + * `sim chat` API turn, and the inbox executor's chat for an email task. + * Shared so those three cannot drift onto different models for the same + * conversation type. + * + * The interactive send path (`lib/copilot/chat/post.ts`) does not read this: + * the chat it creates is stamped with the model it also runs the turn and + * generates the title with, which that module owns separately. */ export const MOTHERSHIP_CHAT_DEFAULT_MODEL = 'claude-opus-4-8' diff --git a/apps/sim/lib/knowledge/upload-metadata.test.ts b/apps/sim/lib/knowledge/upload-metadata.test.ts index 56e8475d938..42cf71ed58c 100644 --- a/apps/sim/lib/knowledge/upload-metadata.test.ts +++ b/apps/sim/lib/knowledge/upload-metadata.test.ts @@ -18,12 +18,28 @@ describe('knowledgeDocumentUploadMetadataSchema', () => { expect(result.error?.issues[0]?.message).toContain('recipe must be one of') }) - it('rejects a lang that is not a BCP-47 tag', () => { + it('rejects a lang outside the enforced subtag shape', () => { const result = knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'zzzz-nonsense!' }, }) expect(result.success).toBe(false) - expect(result.error?.issues[0]?.message).toContain('BCP-47') + expect(result.error?.issues[0]?.message).toContain('hyphen-separated letter and digit subtags') + }) + + /** + * The message promises the shape, not BCP-47 conformance, so a tag the RFC + * rejects for a trailing singleton still parses. Pinned so the two cannot + * drift back apart into a message that claims more than the regex enforces. + */ + it('does not claim the BCP-47 conformance it cannot enforce', () => { + const result = knowledgeDocumentUploadMetadataSchema.safeParse({ + processingOptions: { lang: 'en-a' }, + }) + expect(result.success).toBe(true) + expect( + knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'en_US' } }) + .error?.issues[0]?.message + ).not.toContain('BCP-47') }) it('rejects the underscore locale form callers reach for', () => { @@ -42,7 +58,7 @@ describe('knowledgeDocumentUploadMetadataSchema', () => { expect(result.data?.processingOptions).toEqual({ recipe: 'default', lang: 'en' }) }) - it('accepts a multi-subtag BCP-47 tag', () => { + it('accepts a multi-subtag language tag', () => { expect( knowledgeDocumentUploadMetadataSchema.safeParse({ processingOptions: { lang: 'zh-Hant-TW' } }) .success @@ -68,7 +84,7 @@ describe('persistedKnowledgeDocumentUploadMetadataSchema', () => { expect(parsed.tag1).toBe('product') }) - it('drops a lang persisted before the BCP-47 shape landed instead of throwing', () => { + it('drops a lang persisted before the language-tag shape landed instead of throwing', () => { const parsed = persistedKnowledgeDocumentUploadMetadataSchema.parse({ processingOptions: { recipe: 'default', lang: 'en_US' }, }) diff --git a/apps/sim/lib/knowledge/upload-metadata.ts b/apps/sim/lib/knowledge/upload-metadata.ts index ddb32ea21d7..8b5c96c4ad9 100644 --- a/apps/sim/lib/knowledge/upload-metadata.ts +++ b/apps/sim/lib/knowledge/upload-metadata.ts @@ -21,10 +21,17 @@ export const KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES = ['default', ...RECURSIVE_RECIPE export type KnowledgeDocumentUploadRecipe = (typeof KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES)[number] /** - * BCP-47 language tag: a 2-8 letter primary subtag followed by any number of - * alphanumeric subtags, e.g. `en`, `en-US`, `zh-Hant-TW`. + * The shape a language tag must have: a 2-8 letter primary subtag followed by + * any number of alphanumeric subtags, e.g. `en`, `en-US`, `zh-Hant-TW`. + * + * A shape check, not BCP-47 conformance. It still admits a malformed tag such + * as `en-a`, whose trailing singleton RFC 5646 requires to be followed by + * extension subtags. `lang` is carried through the processing payload untouched + * and read by nothing that chunks or parses the document, so a parser sized to + * reject that would cost more than the field is worth — the message below + * therefore claims only the shape that is actually enforced. */ -const BCP47_LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/ +const LANGUAGE_TAG_SHAPE = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/ const knowledgeDocumentUploadTagSchema = z .string() @@ -48,7 +55,10 @@ const recipeSchema = z.enum(KNOWLEDGE_DOCUMENT_UPLOAD_RECIPES, { const langSchema = z .string() .max(35, 'lang cannot exceed 35 characters') - .regex(BCP47_LANGUAGE_TAG, 'lang must be a BCP-47 language tag, for example "en" or "en-US"') + .regex( + LANGUAGE_TAG_SHAPE, + 'lang must be hyphen-separated letter and digit subtags, for example "en" or "en-US"' + ) /** Persisted metadata stored with a resumable Knowledge document upload session. */ export const knowledgeDocumentUploadMetadataSchema = z diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts index eaaf105c0a9..e9e8935b900 100644 --- a/apps/sim/lib/mothership/inbox/executor.test.ts +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -13,11 +13,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckWorkspaceAccess, mockGetUserEntityPermissions, + mockResolveOrCreateChat, mockRunHeadlessCopilotLifecycle, mockSendInboxResponse, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), mockGetUserEntityPermissions: vi.fn(), + mockResolveOrCreateChat: vi.fn(), mockRunHeadlessCopilotLifecycle: vi.fn(), mockSendInboxResponse: vi.fn(), })) @@ -34,7 +36,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ })) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ - resolveOrCreateChat: vi.fn(), + resolveOrCreateChat: mockResolveOrCreateChat, })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ @@ -94,6 +96,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), })) +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { executeInboxTask } from '@/lib/mothership/inbox/executor' const INBOX_TASK = { @@ -132,6 +135,12 @@ describe('Inbox execution actor', () => { chatId: 'chat-1', }) mockSendInboxResponse.mockResolvedValue('response-1') + mockResolveOrCreateChat.mockResolvedValue({ + chatId: 'chat-1', + chat: { id: 'chat-1' }, + conversationHistory: [], + isNew: true, + }) dbChainMockFns.returning .mockResolvedValueOnce([{ id: 'task-1' }]) .mockResolvedValueOnce([{ model: 'claude-opus-4-8' }]) @@ -205,6 +214,23 @@ describe('Inbox execution actor', () => { expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() }) + it('stamps the shared mothership model on the chat it creates for a task', async () => { + queueTableRows(schemaMock.mothershipInboxTask, [{ ...INBOX_TASK, chatId: null }]) + queueTableRows(schemaMock.workspace, [WORKSPACE]) + queueTableRows(schemaMock.user, [{ id: 'member-1' }]) + mockGetUserEntityPermissions.mockResolvedValue('write') + + await executeInboxTask('task-1') + + expect(mockResolveOrCreateChat).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + type: 'mothership', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, + }) + ) + }) + it('leaves an external sender with no permission at none rather than promoting to read', async () => { queueTableRows(schemaMock.mothershipInboxTask, [INBOX_TASK]) queueTableRows(schemaMock.workspace, [WORKSPACE]) diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 35d21df8cb0..345c5f1c992 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -14,6 +14,7 @@ import { } from '@/lib/copilot/chat/persisted-message' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' import { chatPubSub } from '@/lib/copilot/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' @@ -146,7 +147,7 @@ export async function executeInboxTask(taskId: string): Promise { const chatResult = await resolveOrCreateChat({ userId, workspaceId: ws.id, - model: 'claude-opus-4-8', + model: MOTHERSHIP_CHAT_DEFAULT_MODEL, type: 'mothership', }) chatId = chatResult.chatId diff --git a/apps/sim/lib/secrets/application/use-cases.test.ts b/apps/sim/lib/secrets/application/use-cases.test.ts index 4a921ab23ac..b2253f9c069 100644 --- a/apps/sim/lib/secrets/application/use-cases.test.ts +++ b/apps/sim/lib/secrets/application/use-cases.test.ts @@ -512,6 +512,26 @@ describe('secret application use cases', () => { expect(mocks.setWorkspace).not.toHaveBeenCalled() }) + it('reports a secret deleted between the metadata write and the response read as not found', async () => { + mocks.updateWorkspaceMetadata.mockResolvedValue({ + created: false, + updatedAt: personalUpdatedAt, + }) + mocks.listCredentials.mockResolvedValue({ data: [], nextCursorKeys: null }) + + await expect( + setSecretUseCase.execute({ + principal: session, + input: { + workspaceId: workspace.workspaceId, + name: secret.envKey, + scope: 'workspace', + unredacted: true, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + it('refuses a workspace write that names none of the three writable fields', async () => { await expect( setSecretUseCase.execute({ diff --git a/apps/sim/lib/secrets/application/use-cases.ts b/apps/sim/lib/secrets/application/use-cases.ts index 29d63bba5ef..f4150e16c87 100644 --- a/apps/sim/lib/secrets/application/use-cases.ts +++ b/apps/sim/lib/secrets/application/use-cases.ts @@ -341,11 +341,19 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({ unredacted: input.unredacted, }) if (!metadata) throw new OrchestrationError('not_found', 'Secret not found') - const updated = await getWorkspaceSecretMetadata({ + /** + * The response read is a second statement, so a delete committed between + * the two leaves nothing to report back. That is the same disappearance + * the miss above answers, so it answers the same way rather than raising + * the unclassified fault a "must exist" read would. + */ + const updated = await findSecretMetadata({ workspaceId: context.workspaceId, userId, + scope: 'workspace', name: input.name, }) + if (!updated) throw new OrchestrationError('not_found', 'Secret not found') return { secret: updated, userId, created: false } } diff --git a/apps/sim/lib/skills/application/editor-use-cases.test.ts b/apps/sim/lib/skills/application/editor-use-cases.test.ts index df969aeb040..531dda3f3ce 100644 --- a/apps/sim/lib/skills/application/editor-use-cases.test.ts +++ b/apps/sim/lib/skills/application/editor-use-cases.test.ts @@ -168,6 +168,39 @@ describe('skill editor application use cases', () => { expect(mocks.resolvePermission).toHaveBeenCalled() }) + /** + * The sort orders a copy, never the array the loader handed back. `listSkillEditors` + * builds a fresh array today so nothing else can observe the mutation, but the use + * case does not own that array and must not reorder it for whatever reads it next. + */ + it('sorts without reordering the array the loader returned', async () => { + queueSkill() + const loaded = [ + targetEditor, + { + ...targetEditor, + id: 'workspace-admin-user-3', + userId: 'user-3', + userName: 'Grace', + userEmail: 'grace@example.com', + isWorkspaceAdmin: true, + }, + ] + mocks.listEditors.mockResolvedValue(loaded) + + await listSkillEditorsUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + skillId: SKILL_ID, + sortBy: 'name', + sortOrder: 'desc', + }, + }) + + expect(loaded.map(({ userId }) => userId)).toEqual([targetEditor.userId, 'user-3']) + }) + it('creates an explicit grant and audits the authoritative result', async () => { queueSkill() dbChainMockFns.returning.mockResolvedValueOnce([{ id: targetEditor.id }]) @@ -364,11 +397,29 @@ describe('skill editor application use cases', () => { }) ).rejects.toMatchObject({ code: 'validation', - message: 'workspaceId is required to list the editors of a built-in skill', + message: + 'Listing the editors of a built-in skill requires a workspace scope to authorize against', }) expect(mocks.loadWorkspace).not.toHaveBeenCalled() expect(mocks.listEditors).not.toHaveBeenCalled() }) + + /** + * The internal members contract has no workspace slot, and the v2 contract + * makes `workspaceId` a required query param that is rejected before this + * branch runs. So no caller that reaches this refusal can act on the field + * name, and the message must not spell one. + */ + it('refuses without naming a wire field the caller cannot send', async () => { + const error = await listSkillEditorsUseCase + .execute({ + principal, + input: { skillId: BUILTIN_ID, sortBy: 'email', sortOrder: 'asc' }, + }) + .catch((caught: Error) => caught) + + expect((error as Error).message).not.toMatch(/workspaceId/) + }) }) }) diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 43f7bfb1886..278c0208d3c 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -108,15 +108,22 @@ async function resolveSkillEditorContext( * * A built-in skill owns no row, so there is no workspace to derive scope from * and nothing to authorize the caller against. The read therefore requires the - * caller to name the workspace rather than guessing one: an inferred workspace + * caller to assert the workspace rather than guessing one: an inferred workspace * would authorize against a scope the caller never asserted. + * + * The refusal names the missing scope, not a wire field. This use case is shared + * by both editor surfaces and neither can act on a field name: v2 takes a + * required `workspaceId` query param, so its contract rejects the omission + * before this branch runs, while the internal members route maps no workspace id + * and has no slot to send one. Naming the field would tell the only caller that + * reaches this message to send something it cannot send. */ async function resolveSkillEditorListContext(input: ListSkillEditorsInput): Promise { if (isBuiltinSkillId(input.skillId)) { if (!input.workspaceId) { throw new OrchestrationError( 'validation', - 'workspaceId is required to list the editors of a built-in skill' + 'Listing the editors of a built-in skill requires a workspace scope to authorize against' ) } return resolveSkillContext(input.workspaceId, input.skillId) @@ -430,7 +437,7 @@ export const listSkillEditorsUseCase = defineAuthorizedWorkspaceUseCase({ workspaceId: context.workspaceId, }) const direction = input.sortOrder === 'asc' ? 1 : -1 - const sorted = editors.sort((left, right) => { + const sorted = [...editors].sort((left, right) => { const leftValue = input.sortBy === 'email' ? (left.userEmail ?? '') : (left.userName ?? '') const rightValue = input.sortBy === 'email' ? (right.userEmail ?? '') : (right.userName ?? '') const primary = leftValue.localeCompare(rightValue) diff --git a/apps/sim/lib/table/application/tables.ts b/apps/sim/lib/table/application/tables.ts index a71b1d64dcd..001c3ba9f13 100644 --- a/apps/sim/lib/table/application/tables.ts +++ b/apps/sim/lib/table/application/tables.ts @@ -41,7 +41,10 @@ export interface ListTablesInput { * its third value, `'all'`, would mix archived rows into a page projected by * the strict folder-path resolver, which throws on the dangling `folderId` a * folder archive leaves behind. - * Mirrors `ListWorkflowsInput['scope']`. + * + * The value set is the one `ListWorkflowsInput['scope']` accepts; the + * optionality is not, since that sibling requires a scope where this one + * defaults an absent scope through to the query. */ scope?: 'active' | 'archived' folderPath?: string diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 32189bcdb0e..a9acf0dc61a 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -281,6 +281,36 @@ describe('applyWorkflowOperations', () => { ).rejects.toBe(conflict) }) + /** + * A committed apply reports `[...validation.warnings, ...persisted.warnings]`, + * the second half raised by the preparation step inside the write. A dry run + * that dropped that half would not tell a caller its dangling edge is about + * to disappear — the one thing a preview exists to say. + */ + it('reports the preparation warnings the committed apply would', async () => { + mocks.applyOperations.mockReturnValue({ + state: { + blocks: { 'block-1': BLOCK }, + edges: [{ id: 'edge-9', source: 'block-1', target: 'block-missing' }], + loops: {}, + parallels: {}, + }, + validationErrors: [], + skippedItems: [], + }) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: ['validation note'] }) + + const dry = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(dry.warnings).toEqual([ + 'validation note', + 'Dropped edge "edge-9": edge references a missing block', + ]) + }) + /** The preview is worthless if it does not carry the findings. */ it('reports the same lint a committed apply would', async () => { const dry = await applyWorkflowOperations.execute({ diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index d83d8e5d6ee..ff5a34aebb9 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -365,17 +365,27 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ */ if (input.dryRun) { /** - * The id check the committed write runs, on the ids that write would - * actually insert — the prepared graph, not the engine's output — so a - * dry run cannot report success for a body whose commit is refused with - * a conflict. + * The same preparation the committed write runs, so a dry run checks the + * ids that write would actually insert — the prepared graph, not the + * engine's output — and reports the notes that write would raise. Without + * it a dry run could report success, and no warnings, for a body whose + * commit is refused with a conflict or silently sanitized. + * + * `prepareWorkflowStateForPersistence` is **not** pure: its sanitization + * step rewrites nested sub-block objects in place, and `graph.blocks` + * holds the very objects this response returns. That is safe only because + * `validateWorkflowState(..., { sanitize: true })` above already ran the + * same sanitizer over these blocks, so this second pass writes back the + * values that are already there. Keep that call ahead of this one. */ + const prepared = prepareWorkflowStateForPersistence({ + blocks: graph.blocks, + edges: graph.edges, + }) await assertWorkflowGraphIdsUnclaimed( db, context.workflowId, - collectWorkflowGraphIds( - prepareWorkflowStateForPersistence({ blocks: graph.blocks, edges: graph.edges }).state - ) + collectWorkflowGraphIds(prepared.state) ) logger.info('Evaluated workflow operations without persisting', { @@ -397,7 +407,7 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ inputValidationErrors: validationErrors, mintedBlockIds, lint, - warnings: validation.warnings, + warnings: [...validation.warnings, ...prepared.warnings], needsRedeployment: await checkNeedsRedeployment(context.workflowId), dryRun: true, } diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts index 3c3541c0fba..516e73fb092 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -216,6 +216,39 @@ describe('replaceWorkflowNormalizedState', () => { expect(mocks.save).toHaveBeenCalled() }) + /** + * A block record's key is a label; `saveWorkflowToNormalizedTables` inserts + * `block.id`. Subflow rows are the opposite — their ids come from + * `generateLoopBlocks`, which keys every container by its record key. So a + * body whose key and id diverge must be checked on `Object.values` for + * blocks and on `Object.keys` for subflows, or the pre-check reads ids the + * write never inserts and misses the ones it does. `dbChainMock` resolves + * rows without evaluating predicates, so this is asserted on the composed + * `inArray` rather than on a canned result. + */ + it('checks the ids the write inserts: block values, subflow record keys', async () => { + mocks.prepare.mockReturnValue({ + state: { + blocks: { + 'block-key': { ...BLOCK, id: 'block-value' }, + 'loop-key': { ...LOOP_BLOCK, id: 'loop-value' }, + }, + edges: [], + loops: { 'loop-key': { id: 'loop-key' } }, + parallels: {}, + }, + warnings: [], + }) + + await replaceWorkflowNormalizedState(input()) + + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowBlocks.id, [ + 'block-value', + 'loop-value', + ]) + expect(inArray).toHaveBeenCalledWith(schemaMock.workflowSubflows.id, ['loop-key']) + }) + it('refuses an edge id another workflow owns', async () => { mocks.prepare.mockReturnValue({ state: { ...PREPARED, edges: [EDGE] }, @@ -296,10 +329,18 @@ describe('replaceWorkflowNormalizedState', () => { await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) }) - it('leaves an unrelated database fault unclassified', async () => { - const failure = wrapDriverError( - Object.assign(new Error('deadlock detected'), { code: '40P01' }) - ) + /** + * Not every 23505 carries a constraint name: a violation raised by a bare + * unique index, or one whose driver dropped the field, arrives with none. + * Exact matching must read that as "not a graph id" — treating an absent + * name as a match would relabel unrelated unique violations across the + * whole write as a graph-id conflict. + */ + it('leaves a 23505 carrying no constraint name unclassified', async () => { + const cause = Object.assign(new Error('duplicate key value violates unique constraint'), { + code: '23505', + }) + const failure = wrapDriverError(cause) mocks.save.mockRejectedValue(failure) await expect(replaceWorkflowNormalizedState(input())).rejects.toBe(failure) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts index 4c37da30c3c..9355a0bc45e 100644 --- a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -37,13 +37,21 @@ export interface WorkflowGraphIds { * by both the dry-run preview and the committed write, so the two cannot * disagree about what is about to be claimed. * + * Each family is read from whichever side `saveWorkflowToNormalizedTables` + * inserts, which is not the same side for all three. Blocks are inserted as + * `block.id` — the record's own field, taken from `Object.values` — so a body + * whose record key differs from the block's `id` is checked on the value that + * reaches the table. Subflow rows are the opposite: their ids come from + * `generateLoopBlocks`/`generateParallelBlocks`, which key every container by + * its record key, so those are collected from `Object.keys`. + * * Subflow ids are collected separately even though they are container block * ids: `workflow_subflows` has its own global primary key, so the same value * can be free as a block id and taken as a subflow id. */ export function collectWorkflowGraphIds(state: PreparedWorkflowState): WorkflowGraphIds { return { - blockIds: Object.keys(state.blocks), + blockIds: Object.values(state.blocks).map((block) => block.id), edgeIds: state.edges.map((edge) => edge.id), subflowIds: [...Object.keys(state.loops), ...Object.keys(state.parallels)], } diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index c0d936266e2..f62539bc925 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -296,6 +296,8 @@ describe('login command', () => { it.each([ ['a C0 control character', 'sim-key\u0001rest'], ['a Unicode line separator', 'sim-key\u2028rest'], + ['leading whitespace', ' sim-key'], + ['trailing whitespace', 'sim-key '], ])('stores nothing when the minted key carries %s', async (_label, apiKey) => { // The pre-write check has to refuse exactly what the writer refuses. When it // was the narrower of the two, the settings write landed and the credentials @@ -531,6 +533,20 @@ describe('profiles command', () => { expect(mocks.writeConfigProfile).not.toHaveBeenCalled() }) + it('refuses a workspace id the response could not have stored, naming the response', async () => { + // The id comes off the wire exactly like the login response's, so it is + // checked the same way. Without this the writer still refused it, but with + // a message about the file format rather than the side that produced it. + mocks.request.mockResolvedValue({ + data: { id: 'ws_acme\nendpoint = http://elsewhere.invalid', name: 'Acme', memberCount: 3 }, + }) + + await expect(profiles('add', 'acme', '--workspace', 'ws_acme')).rejects.toThrow( + 'Invalid workspace id "ws_acme endpoint = http://elsewhere.invalid" from the workspace response.' + ) + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + }) + it('refuses a new profile name that would forge a config section', async () => { await expect(profiles('add', 'evil]\n[default', '--workspace', 'ws_acme')).rejects.toThrow( 'Invalid profile name' @@ -641,6 +657,40 @@ describe('profiles command', () => { expect(output).toContain('references missing auth_profile "gone"') }) + it('refuses an unknown profile named by the environment, not just the flag', async () => { + // The same guard, reached through its other input. `SIM_PROFILE=typo sim + // profiles` must fail exactly like `sim profiles --profile typo`. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown profile "bogus".') + }) + process.env.SIM_PROFILE = 'bogus' + + try { + await expect(profiles('list')).rejects.toThrow('Unknown profile "bogus".') + expect(console.log).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(process.env, 'SIM_PROFILE') + } + }) + + it('refuses an output format it does not know rather than printing a table', async () => { + // A script asking for a machine format must not be handed human output with + // exit 0. The catch exists to tolerate a broken *profile*, not a bad flag. + mocks.listProfiles.mockReturnValue(['default']) + mocks.profileFrom.mockImplementation(() => { + throw new mocks.ProfileConfigError('Unknown output format "jsonl" from env.') + }) + process.env.SIM_OUTPUT = 'jsonl' + + try { + await expect(profiles('list')).rejects.toThrow('Unknown output format "jsonl" from env.') + expect(console.log).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(process.env, 'SIM_OUTPUT') + } + }) + it('marks a broken profile and still lists the rest', async () => { // `profiles` is the command someone runs *because* a profile is broken, and // one bad auth_profile used to abort the listing with nothing shown at all. diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index ae81d573494..4422be3a226 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -133,10 +133,21 @@ function validateNewProfileName(profileName: string): void { * * It shares {@link FORBIDDEN_IN_VALUE} with the writer rather than copying it: * a second spelling drifted once already, and a key this check accepted but the - * writer rejected stranded the new endpoint beside the previous key. + * writer rejected stranded the new endpoint beside the previous key. Surrounding + * whitespace is the same failure and is refused here for the same reason — the + * writer will not store text it cannot read back unchanged. + * + * Refused rather than trimmed: a minted key is opaque, so the CLI cannot tell + * padding from the credential. Trimming would store a value the server never + * issued and turn a loud, explained failure into a 401 on every later command. */ function requireStorableKey(apiKey: unknown): void { - if (typeof apiKey !== 'string' || !apiKey.trim() || FORBIDDEN_IN_VALUE.test(apiKey)) { + if ( + typeof apiKey !== 'string' || + !apiKey || + apiKey !== apiKey.trim() || + FORBIDDEN_IN_VALUE.test(apiKey) + ) { throw new SimApiError( 'The server returned a malformed API key. Nothing was stored; check the endpoint.', 0 @@ -237,7 +248,11 @@ function addProfileCommand(): Command { writeConfigProfile(profileName, { auth_profile: authProfile, - workspace: workspace.id, + // Server-supplied, exactly like the login response's workspace id, so it + // is checked the same way: the writer would refuse an unstorable one + // anyway, but with a message about the file format rather than the + // response that produced it. + workspace: normalizeWorkspaceId(workspace.id, 'the workspace response'), }) console.log(chalk.green(`✓ Added profile "${profileName}" in ${configPath()}`)) @@ -688,12 +703,15 @@ function profileListingContext(command: Command): { activeName: string; output: const named = globals.profile || process.env.SIM_PROFILE if (named && named !== DEFAULT_PROFILE && !listProfiles().includes(named)) throw error + // A bad format is the caller's own request, not a broken profile: falling + // back to a table would hand a script human output with exit 0. Only the + // profile's *resolution* is tolerated here, never its arguments. const requested = globals.output ?? process.env.SIM_OUTPUT + if (requested && !(OUTPUT_FORMATS as readonly string[]).includes(requested)) throw error + return { activeName: named || DEFAULT_PROFILE, - output: (OUTPUT_FORMATS as readonly string[]).includes(requested as string) - ? (requested as OutputFormat) - : 'table', + output: requested ? (requested as OutputFormat) : 'table', } } } diff --git a/packages/sim-cli/src/commands/protocol/files-get.test.ts b/packages/sim-cli/src/commands/protocol/files-get.test.ts index 3ae61611817..c1750c68512 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.test.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.test.ts @@ -12,9 +12,9 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { Writable } from 'node:stream' -import { setTimeout as sleep } from 'node:timers/promises' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { sleep } from '../../helpers' import { buildGeneratedCommands } from '../../runtime/build' import { isTerminalSafeContentType, diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index fd5cf864c7d..f0220dab97c 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -259,23 +259,31 @@ function program(): Command { return root } +/** + * A workflow id is a bare UUID. `wf_` is the workspace-file prefix, so it never + * names a workflow — spelling one that way here would model the wrong scheme. + */ +const WORKFLOW_ID = '00000000-0000-4000-8000-00000000000a' + async function run(...argv: string[]): Promise { await program().parseAsync(['node', 'sim', 'workflows', 'run', ...argv]) } describe('sim workflows run --follow', () => { it('refuses --follow together with --async', async () => { - await expect(run('wf_1', '--follow', '--async')).rejects.toThrow(/pass one, not both/) + await expect(run(WORKFLOW_ID, '--follow', '--async')).rejects.toThrow(/pass one, not both/) expect(requestRaw).not.toHaveBeenCalled() }) it('refuses stream-only flags without --follow', async () => { - await expect(run('wf_1', '--include-thinking')).rejects.toThrow(/add --follow/) + await expect(run(WORKFLOW_ID, '--include-thinking')).rejects.toThrow(/add --follow/) expect(request).not.toHaveBeenCalled() }) it('refuses --select-output without --follow and sends nothing', async () => { - await expect(run('wf_1', '--select-output', 'agent_1.content')).rejects.toThrow(/add --follow/) + await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( + /add --follow/ + ) expect(request).not.toHaveBeenCalled() expect(requestRaw).not.toHaveBeenCalled() }) @@ -284,7 +292,7 @@ describe('sim workflows run --follow', () => { // The caller just typed a block *name*, which is what this flag accepts and // what `workflows runs get` rejects, so a hint that only repeated the flag // would send them into a second 400. - await expect(run('wf_1', '--select-output', 'agent_1.content')).rejects.toThrow( + await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( /workflows runs get .*--select-output \[\.path\].*block ids, not the block names/s ) }) @@ -292,7 +300,7 @@ describe('sim workflows run --follow', () => { it('tells --async --select-output that no stream is coming, rather than to follow', async () => { // `--async --follow` is refused outright, so "add --follow" would be advice // that cannot be taken. - const failure = await run('wf_1', '--async', '--select-output', 'agent_1.content').catch( + const failure = await run(WORKFLOW_ID, '--async', '--select-output', 'agent_1.content').catch( (error: Error) => error ) @@ -306,7 +314,7 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow', '--select-output', 'agent_1.content') + await run(WORKFLOW_ID, '--follow', '--select-output', 'agent_1.content') expect(requestRaw.mock.calls[0][1].body).toEqual({ stream: true, @@ -318,7 +326,7 @@ describe('sim workflows run --follow', () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--input', '{"topic":"otters"}') + await run(WORKFLOW_ID, '--input', '{"topic":"otters"}') expect(requestRaw).not.toHaveBeenCalled() expect(request).toHaveBeenCalledTimes(1) @@ -329,7 +337,14 @@ describe('sim workflows run --follow', () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--manual', '--trigger', 'slack-trigger', '--input', '{"event":"created"}') + await run( + WORKFLOW_ID, + '--manual', + '--trigger', + 'slack-trigger', + '--input', + '{"event":"created"}' + ) expect(request.mock.calls[0][1].body).toEqual({ input: { event: 'created' }, @@ -341,7 +356,7 @@ describe('sim workflows run --follow', () => { request.mockResolvedValue({ data: { success: true, output: {} } }) vi.spyOn(console, 'log').mockImplementation(() => {}) - await run('wf_1', '--from-block', 'agent-1', '--source-run', 'run-1') + await run(WORKFLOW_ID, '--from-block', 'agent-1', '--source-run', 'run-1') expect(request.mock.calls[0][1].body).toEqual({ run: { @@ -356,7 +371,7 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--manual', '--mock-payload', '--follow') + await run(WORKFLOW_ID, '--manual', '--mock-payload', '--follow') expect(requestRaw.mock.calls[0][1].body).toEqual({ run: { source: 'manual', entry: { type: 'trigger', useMockPayload: true } }, @@ -365,12 +380,16 @@ describe('sim workflows run --follow', () => { }) it('fails fast on invalid manual flag combinations', async () => { - await expect(run('wf_1', '--trigger', 'trigger-1')).rejects.toThrow(/require --manual/) - await expect(run('wf_1', '--from-block', 'agent-1')).rejects.toThrow(/requires --source-run/) - await expect(run('wf_1', '--source-run', 'run-1')).rejects.toThrow(/requires --from-block/) - await expect(run('wf_1', '--manual', '--async')).rejects.toThrow(/does not support --async/) + await expect(run(WORKFLOW_ID, '--trigger', 'trigger-1')).rejects.toThrow(/require --manual/) + await expect(run(WORKFLOW_ID, '--from-block', 'agent-1')).rejects.toThrow( + /requires --source-run/ + ) + await expect(run(WORKFLOW_ID, '--source-run', 'run-1')).rejects.toThrow(/requires --from-block/) + await expect(run(WORKFLOW_ID, '--manual', '--async')).rejects.toThrow( + /does not support --async/ + ) await expect( - run('wf_1', '--manual', '--mock-payload', '--input', '{"event":"created"}') + run(WORKFLOW_ID, '--manual', '--mock-payload', '--input', '{"event":"created"}') ).rejects.toThrow(/cannot be combined/) expect(request).not.toHaveBeenCalled() expect(requestRaw).not.toHaveBeenCalled() @@ -381,10 +400,10 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow', '--input', '{"topic":"otters"}') + await run(WORKFLOW_ID, '--follow', '--input', '{"topic":"otters"}') const [path, init] = requestRaw.mock.calls[0] - expect(path).toBe('/api/v2/workflows/wf_1/execute') + expect(path).toBe(`/api/v2/workflows/${WORKFLOW_ID}/execute`) expect(init.body).toEqual({ input: { topic: 'otters' }, stream: true }) expect(init.headers.accept).toBe('text/event-stream') expect(init.headers['x-sim-stream-protocol']).toBeUndefined() @@ -395,7 +414,7 @@ describe('sim workflows run --follow', () => { vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow', '--include-thinking', '--include-tool-calls') + await run(WORKFLOW_ID, '--follow', '--include-thinking', '--include-tool-calls') const init = requestRaw.mock.calls[0][1] expect(init.body).toMatchObject({ stream: true, includeThinking: true, includeToolCalls: true }) @@ -415,7 +434,7 @@ describe('sim workflows run --follow', () => { const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await run('wf_1', '--follow') + await run(WORKFLOW_ID, '--follow') const printed = stdout.mock.calls.map((call) => String(call[0])).join('\n') expect(JSON.parse(printed)).toEqual({ success: true, output: { answer: 42 } }) @@ -432,7 +451,7 @@ describe('sim workflows run --follow', () => { const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await expect(run('wf_1', '--follow')).rejects.toThrow(/Block agent_1 failed/) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toThrow(/Block agent_1 failed/) expect(stdout).toHaveBeenCalled() }) @@ -444,7 +463,7 @@ describe('sim workflows run --follow', () => { headers: new Headers({ 'content-type': 'application/json' }), } as unknown as Response) - await expect(run('wf_1', '--follow')).rejects.toThrow(/instead of an event stream/) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toThrow(/instead of an event stream/) expect(cancel).toHaveBeenCalled() }) @@ -454,6 +473,6 @@ describe('sim workflows run --follow', () => { ) vi.spyOn(process.stderr, 'write').mockImplementation(() => true) - await expect(run('wf_1', '--follow')).rejects.toBeInstanceOf(SimApiError) + await expect(run(WORKFLOW_ID, '--follow')).rejects.toBeInstanceOf(SimApiError) }) }) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts index eeb3d7eb6b8..0e0e9aa2d47 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-wait.test.ts @@ -89,7 +89,7 @@ function run(payload: RunPayload) { return { data: { runId: 'run_1', - workflowId: 'wf_1', + workflowId: '00000000-0000-4000-8000-00000000000a', startedAt: '2026-08-17T00:00:00.000Z', endedAt: null, durationMs: null, @@ -128,7 +128,7 @@ async function wait(argv: string[] = []): Promise { 'wait', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', ...argv, ]) } @@ -140,7 +140,10 @@ describe('workflows runs wait', () => { await wait() expect(mockRequest).toHaveBeenCalledTimes(3) - expect(mockRequest).toHaveBeenCalledWith('/api/v2/workflows/wf_1/runs/run_1', { method: 'GET' }) + expect(mockRequest).toHaveBeenCalledWith( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1', + { method: 'GET' } + ) expect(process.exitCode).toBe(0) expect(logged.join('\n')).toContain('completed') }) @@ -193,7 +196,7 @@ describe('workflows runs wait', () => { expect(mockRequest).toHaveBeenCalledTimes(1) expect(process.exitCode).toBe(3) expect(errored.join('\n')).toContain( - 'sim workflows runs resume run_1 --workflow wf_1 --context ctx_9' + 'sim workflows runs resume run_1 --workflow 00000000-0000-4000-8000-00000000000a --context ctx_9' ) }) @@ -295,7 +298,16 @@ describe('workflows runs wait', () => { runs.commands.forEach((command) => command.exitOverride()) await expect( - root.parseAsync(['node', 'sim', 'runs', 'wait', 'run_1', 'run_2', '--workflow', 'wf_1']) + root.parseAsync([ + 'node', + 'sim', + 'runs', + 'wait', + 'run_1', + 'run_2', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) ).rejects.toThrow(/too many arguments/) expect(mockRequest).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/config/ini.ts b/packages/sim-cli/src/config/ini.ts index 6f27c4406ab..1ecb1f143bc 100644 --- a/packages/sim-cli/src/config/ini.ts +++ b/packages/sim-cli/src/config/ini.ts @@ -43,6 +43,9 @@ export interface IniDocument { const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/ const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ +/** The character-class body both forbidden-character patterns are built from. */ +const FORBIDDEN_CLASS = '\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029' + /** * Characters a stored value may not contain. * @@ -65,10 +68,10 @@ const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/ * hand-kept copy drifted from this one once already, and the gap let a rejected * write land after an accepted one. */ -export const FORBIDDEN_IN_VALUE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/ +export const FORBIDDEN_IN_VALUE = new RegExp(`[${FORBIDDEN_CLASS}]`) /** As {@link FORBIDDEN_IN_VALUE}, plus the brackets that would close or open a header. */ -const FORBIDDEN_IN_NAME = /[\u0000-\u001f\u007f-\u009f\u2028\u2029[\]]/ +const FORBIDDEN_IN_NAME = new RegExp(`[${FORBIDDEN_CLASS}[\\]]`) /** Keys have to round-trip through the reader's own key pattern. */ const WRITABLE_KEY = /^[A-Za-z0-9_.-]+$/ @@ -88,10 +91,12 @@ function assertWritable(text: string, what: string, forbidden: RegExp): void { `Refusing to write ${what}: line breaks and control characters cannot be stored in the ~/.sim files, because the format has no way to escape them.` ) } - // The reader trims both section names and values, so padded text comes back - // as something else: the read reports the setting missing although the write - // reported success, and the next write appends a second block or key instead - // of updating the one already there. + // The reader trims both section names and values, so padded text never comes + // back as written. For a section name that also corrupts the file: the block + // is written under the padded name but read under the trimmed one, so the + // next write finds no match and appends a second block. For a value it is + // quieter but no more acceptable — the key reads back trimmed, so a padded + // secret is silently stored as a different secret than the caller passed. if (text !== text.trim()) { throw new ProfileConfigError( `Refusing to write ${what}: leading or trailing whitespace is not preserved by the ~/.sim files, so it would not read back as written.` diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index b6e7e8407ab..cfdf6c5a0b9 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -242,6 +242,20 @@ describe('profile resolution', () => { expect(resolveProfile({ endpoint: 'https://sim.ai///' }).endpoint).toBe('https://sim.ai') }) + it('trims a padded endpoint instead of storing text the writer would refuse', () => { + // `new URL()` tolerates padding and hands the string straight back, but the + // config writer refuses it. Untrimmed, `login --endpoint " https://…"` threw + // only after the device flow had already minted and discarded a key. + expect(resolveProfile({ endpoint: ' https://sim.ai/ ' }).endpoint).toBe('https://sim.ai') + + writeConfigProfile('default', { endpoint: 'https://sim.ai' }) + expect(() => + writeConfigProfile('default', { + endpoint: resolveProfile({ endpoint: ' https://staging.sim.ai ' }).endpoint, + }) + ).not.toThrow() + }) + it('fails fast on an endpoint Node cannot parse, naming the source', () => { expect(() => resolveProfile({ endpoint: 'not-a-url' })).toThrow( 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://www.sim.ai or http://localhost:3000' @@ -369,6 +383,21 @@ describe('config file injection', () => { ) }) + it('redacts control characters out of the rejected name, like its sibling', () => { + // The message lands in a terminal, so echoing the rejected name verbatim + // would let it carry escape sequences there. `normalizeWorkspaceId` already + // redacts through the same pattern. + let message = '' + try { + validateProfileName('evil\u001b[2J\nname') + } catch (error) { + message = (error as Error).message + } + + expect(message).toContain('Invalid profile name "evil [2J name"') + expect(message).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/) + }) + it('still resolves an existing profile whose name predates the rule', () => { // The shape rule governs creation only: a hand-written section keeps // working, whatever it is called. diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 8492ec2458e..1d79d0c6fb4 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -62,8 +62,11 @@ export const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ */ export function validateProfileName(name: string): void { if (!PROFILE_NAME_PATTERN.test(name)) { + // Redacted the same way as in `normalizeWorkspaceId`: the rejected name is + // untrusted text, and echoing its control characters into a terminal is how + // an error message becomes an escape-sequence delivery vehicle. throw new ProfileConfigError( - `Invalid profile name "${name}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.` + `Invalid profile name "${name.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}". Use letters, numbers, dots, underscores, or hyphens, starting with a letter or number.` ) } } @@ -305,16 +308,22 @@ export function deleteProfile(profile: string): { config: boolean; credentials: * the user has to edit. */ export function normalizeEndpoint(endpoint: string, source: string): string { - // A trailing slash here produces `https://sim.ai//api/v2/...`, which some - // proxies 404 rather than normalize. - const trimmed = endpoint.replace(/\/+$/, '') + // Surrounding whitespace is stripped first, and for the same reason as in + // `normalizeWorkspaceId`: `new URL()` tolerates padding and hands the padded + // string straight back, but the config writer refuses it. Without this, + // `login --endpoint " https://…"` failed *after* the device flow had already + // minted a key, discarding it and reporting a file-format problem instead of + // naming the flag. It also has to come first so the slash strip sees the real + // end of the URL — and that strip is there because a trailing slash produces + // `https://sim.ai//api/v2/...`, which some proxies 404 rather than normalize. + const trimmed = endpoint.trim().replace(/\/+$/, '') let parsed: URL try { parsed = new URL(trimmed) } catch { throw new ProfileConfigError( - `Invalid endpoint "${endpoint}" from ${source}. Use an absolute URL, e.g. ${DEFAULT_ENDPOINT} or http://localhost:3000` + `Invalid endpoint "${endpoint.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. Use an absolute URL, e.g. ${DEFAULT_ENDPOINT} or http://localhost:3000` ) } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index de59df442f8..5bfbcc7f0a9 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -495,15 +495,26 @@ describe('commands parsed through commander', () => { expect(tablePath).toBe('/api/v2/tables/tbl_1') expect(tableOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) - const [workflowPath, workflowOptions] = await run(['workflow', 'mv', 'wf_1', 'Archive']) - expect(workflowPath).toBe('/api/v2/workflows/wf_1') + const [workflowPath, workflowOptions] = await run([ + 'workflow', + 'mv', + '00000000-0000-4000-8000-00000000000a', + 'Archive', + ]) + expect(workflowPath).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a') expect(workflowOptions.body).toEqual({ folderPath: 'Archive' }) const [knowledgePath, knowledgeOptions] = await run(['kb', 'mv', 'kb_1', 'Archive']) expect(knowledgePath).toBe('/api/v2/knowledge/kb_1') expect(knowledgeOptions.body).toEqual({ workspaceId: 'ws_local', folderPath: 'Archive' }) - const [, updateOptions] = await run(['workflow', 'update', 'wf_1', '--description', 'Updated']) + const [, updateOptions] = await run([ + 'workflow', + 'update', + '00000000-0000-4000-8000-00000000000a', + '--description', + 'Updated', + ]) expect(updateOptions.body).toEqual({ description: 'Updated' }) const moveHelp = commandAt('workflows', 'mv').helpInformation() @@ -620,8 +631,16 @@ describe('commands parsed through commander', () => { }) it('comma-joins a repeated list flag', async () => { - const [, options] = await run(['logs', 'list', '--workflow', 'wf_1', 'wf_2']) - expect(options.query).toMatchObject({ workflowIds: 'wf_1,wf_2' }) + const [, options] = await run([ + 'logs', + 'list', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + '00000000-0000-4000-8000-00000000000b', + ]) + expect(options.query).toMatchObject({ + workflowIds: '00000000-0000-4000-8000-00000000000a,00000000-0000-4000-8000-00000000000b', + }) }) it('injects the profile workspace without a flag', async () => { @@ -644,11 +663,21 @@ describe('commands parsed through commander', () => { expect(help).toContain('agent_1.content') expect(help).not.toContain('--output ') - const [, withoutInput] = await run(['workflows', 'run', 'wf_1'], { data: { success: true } }) + const [, withoutInput] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a'], + { data: { success: true } } + ) expect(withoutInput.body).toEqual({}) const [, selected] = await run( - ['workflows', 'run', 'wf_1', '--select-output', 'agent.answer', 'save.result'], + [ + 'workflows', + 'run', + '00000000-0000-4000-8000-00000000000a', + '--select-output', + 'agent.answer', + 'save.result', + ], { data: { success: true } } ) expect(selected.body).toEqual({ selectedOutputs: ['agent.answer', 'save.result'] }) @@ -747,23 +776,38 @@ describe('commands parsed through commander', () => { 'get', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', '--include-output', '--select-output', 'agent.content', 'writer.text', ]) - expect(path).toBe('/api/v2/workflows/wf_1/runs/run_1') + expect(path).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1') expect(options.query).toEqual({ includeOutput: true, selectedOutputs: 'agent.content,writer.text', }) - const [listPath] = await run(['workflows', 'runs', 'list', '--workflow', 'wf_1']) - expect(listPath).toBe('/api/v2/workflows/wf_1/runs') + const [listPath] = await run([ + 'workflows', + 'runs', + 'list', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) + expect(listPath).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs') - const [cancelPath] = await run(['workflows', 'runs', 'cancel', 'run_1', '--workflow', 'wf_1']) - expect(cancelPath).toBe('/api/v2/workflows/wf_1/runs/run_1/cancel') + const [cancelPath] = await run([ + 'workflows', + 'runs', + 'cancel', + 'run_1', + '--workflow', + '00000000-0000-4000-8000-00000000000a', + ]) + expect(cancelPath).toBe( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1/cancel' + ) const resumeHelp = commandAt('workflows', 'runs', 'resume').helpInformation() expect(resumeHelp).toContain('') @@ -776,13 +820,15 @@ describe('commands parsed through commander', () => { 'resume', 'run_1', '--workflow', - 'wf_1', + '00000000-0000-4000-8000-00000000000a', '--context', 'ctx_1', '--input', '{"approved":true}', ]) - expect(resumePath).toBe('/api/v2/workflows/wf_1/runs/run_1/resume') + expect(resumePath).toBe( + '/api/v2/workflows/00000000-0000-4000-8000-00000000000a/runs/run_1/resume' + ) expect(resumeOptions.body).toEqual({ contextId: 'ctx_1', input: { approved: true }, @@ -890,8 +936,12 @@ describe('single-resource rendering', () => { // the record builder kept only scalars, so `workflow` and `state` — the // entire export — vanished with no indication anything was missing. const printed = await lines( - ['workflows', 'get', 'wf_1'], - { id: 'wf_1', name: 'Onboarding', inputs: [{ name: 'email', type: 'string' }] }, + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + { + id: '00000000-0000-4000-8000-00000000000a', + name: 'Onboarding', + inputs: [{ name: 'email', type: 'string' }], + }, 'text' ) @@ -900,9 +950,16 @@ describe('single-resource rendering', () => { }) it('truncates a nested value in the table, and only there', async () => { - const payload = { id: 'wf_1', state: { blocks: 'x'.repeat(5000) } } + const payload = { + id: '00000000-0000-4000-8000-00000000000a', + state: { blocks: 'x'.repeat(5000) }, + } - const table = await lines(['workflows', 'get', 'wf_1'], payload, 'table') + const table = await lines( + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + payload, + 'table' + ) const clamped = table.find((line) => line.startsWith('state')) ?? '' expect(clamped.length).toBeLessThan(300) expect(clamped).toMatch(/…$/) @@ -910,7 +967,11 @@ describe('single-resource rendering', () => { // `text` is the format built for pipes, so it carries the whole value: the // clamp is a legibility cap on the human table, and clamping before the // format branch silently truncated commands whose output is one long value. - const piped = await lines(['workflows', 'get', 'wf_1'], payload, 'text') + const piped = await lines( + ['workflows', 'get', '00000000-0000-4000-8000-00000000000a'], + payload, + 'text' + ) const whole = piped.find((line) => line.startsWith('state')) ?? '' expect(whole).toContain('x'.repeat(5000)) }) @@ -919,15 +980,20 @@ describe('single-resource rendering', () => { // Redirecting this to a file has to yield something `import` accepts, so // `table`/`text` — which flatten and truncate — must not be honoured here. const printed = await lines( - ['workflows', 'export', 'wf_1'], - { version: '1.0', exportedAt: 'now', workflow: { id: 'wf_1' }, state: { blocks: {} } }, + ['workflows', 'export', '00000000-0000-4000-8000-00000000000a'], + { + version: '1.0', + exportedAt: 'now', + workflow: { id: '00000000-0000-4000-8000-00000000000a' }, + state: { blocks: {} }, + }, 'text' ) expect(JSON.parse(printed.join('\n'))).toEqual({ version: '1.0', exportedAt: 'now', - workflow: { id: 'wf_1' }, + workflow: { id: '00000000-0000-4000-8000-00000000000a' }, state: { blocks: {} }, }) }) @@ -1319,19 +1385,25 @@ describe('a body field the contract clears with null', () => { * is as far as the terminal goes, and the word is only ever the word. */ it('sends an empty string as empty and the word null as text, with no companion flag', async () => { - const [, empty] = await run(['workflows', 'update', 'wf_1', '--description', ''], { - data: { id: 'wf_1' }, - }) + const [, empty] = await run( + ['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--description', ''], + { + data: { id: '00000000-0000-4000-8000-00000000000a' }, + } + ) expect(empty.body).toMatchObject({ description: '' }) - const [, literal] = await run(['workflows', 'update', 'wf_1', '--description', 'null'], { - data: { id: 'wf_1' }, - }) + const [, literal] = await run( + ['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--description', 'null'], + { + data: { id: '00000000-0000-4000-8000-00000000000a' }, + } + ) expect(literal.body).toMatchObject({ description: 'null' }) - await expect(run(['workflows', 'update', 'wf_1', '--no-description'])).rejects.toThrow( - /unknown option/ - ) + await expect( + run(['workflows', 'update', '00000000-0000-4000-8000-00000000000a', '--no-description']) + ).rejects.toThrow(/unknown option/) }) }) @@ -1662,7 +1734,7 @@ describe('flags the root program would swallow', () => { /** * Commander matches the root's own options anywhere in argv, including after a * subcommand name, so a leaf declaring one never sees it: `sim workflows - * rollback wf_1 --version 1` printed the CLI version and exited 0 without + * rollback 00000000-0000-4000-8000-00000000000a --version 1` printed the CLI version and exited 0 without * issuing a request — a rollback that silently did nothing, with no output to * tell anyone. The generic sweep is the point; the rollback assertion below * only records the one case that got through. @@ -1682,13 +1754,20 @@ describe('flags the root program would swallow', () => { it('exposes the rollback target version under a name of its own', async () => { const [path, init] = await run( - ['workflows', 'rollback', 'wf_1', '--to-version', '3', '--yes'], + [ + 'workflows', + 'rollback', + '00000000-0000-4000-8000-00000000000a', + '--to-version', + '3', + '--yes', + ], { data: {}, } ) - expect(path).toBe('/api/v2/workflows/wf_1/rollback') + expect(path).toBe('/api/v2/workflows/00000000-0000-4000-8000-00000000000a/rollback') expect(init.body).toMatchObject({ version: 3 }) }) }) @@ -1724,21 +1803,29 @@ describe('headers the route contract declares', () => { * operation the assembled tree no longer offers. */ it('sends a declared header when the flag is passed, and omits the slot when it is not', async () => { - const [, sent] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, sent] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(sent.headers).toEqual({ 'x-run-id': 'run_mine' }) - const [, unset] = await run(['workflows', 'run', 'wf_1'], { data: { status: 'completed' } }) + const [, unset] = await run(['workflows', 'run', '00000000-0000-4000-8000-00000000000a'], { + data: { status: 'completed' }, + }) expect(unset.headers).toBeUndefined() }) it('sends no headers for an operation whose contract declares none', async () => { // Paired with an operation that does declare one, so the absence below means // "declares none" rather than "never sends headers". - const [, sent] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, sent] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(sent.headers).toEqual({ 'x-run-id': 'run_mine' }) const [, options] = await run(['tables', 'list']) @@ -1751,7 +1838,16 @@ describe('headers the route contract declares', () => { */ it('does not expose the call-chain header Sim writes for itself', async () => { await expect( - run(['workflows', 'run', 'wf_1', '--x-sim-via', 'wf_0'], { data: { status: 'completed' } }) + run( + [ + 'workflows', + 'run', + '00000000-0000-4000-8000-00000000000a', + '--x-sim-via', + '00000000-0000-4000-8000-000000000000', + ], + { data: { status: 'completed' } } + ) ).rejects.toThrow(/unknown option/) }) @@ -1761,13 +1857,18 @@ describe('headers the route contract declares', () => { * spelled as a raw HTTP header. */ it('exposes the run-id header under a domain name, not its wire spelling', async () => { - const [, options] = await run(['workflows', 'run', 'wf_1', '--run-id', 'run_mine'], { - data: { status: 'completed' }, - }) + const [, options] = await run( + ['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--run-id', 'run_mine'], + { + data: { status: 'completed' }, + } + ) expect(options.headers).toEqual({ 'x-run-id': 'run_mine' }) await expect( - run(['workflows', 'run', 'wf_1', '--x-run-id', 'run_mine'], { data: { status: 'completed' } }) + run(['workflows', 'run', '00000000-0000-4000-8000-00000000000a', '--x-run-id', 'run_mine'], { + data: { status: 'completed' }, + }) ).rejects.toThrow(/unknown option/) }) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 24de9c926ff..3748797d7f4 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -114,6 +114,15 @@ describe('buildRequest', () => { }) }) + it('still sends an explicit zero, which is a value the caller chose', () => { + expect(buildRequest('listLogs', [], { minCost: '0' }, WORKSPACE).query).toMatchObject({ + minCost: 0, + }) + expect( + buildRequest('readFileText', ['wf_1'], { maxBytes: '0' }, WORKSPACE).query + ).toMatchObject({ maxBytes: 0 }) + }) + it('still sends an empty body string, which is how a description is cleared', () => { expect(buildRequest('updateWorkflow', ['wf_1'], { description: '' }, WORKSPACE).body).toEqual({ description: '', @@ -145,6 +154,34 @@ describe('buildRequest', () => { ) }) + /** + * `Number('')` is `0`, so a blank numeric filter coerced into a real one: + * `--max-cost ""` asked for runs costing at most nothing and answered `0` + * rows, the same silent-wrong-result the blank-string refusal exists to + * remove. + */ + it('rejects a blank numeric query filter, which coercion would read as 0', () => { + expect(() => buildRequest('listLogs', [], { maxCost: '' }, WORKSPACE)).toThrow( + '--max-cost cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { minDurationMs: '' }, WORKSPACE)).toThrow( + '--min-duration-ms cannot be empty' + ) + expect(() => buildRequest('readFileText', ['wf_1'], { maxBytes: '' }, WORKSPACE)).toThrow( + '--max-bytes cannot be empty' + ) + }) + + /** + * A paginating `limit` is the walk size, not a filter, and the pager reads + * it from the flags itself — refusing a blank one in wording that says what + * `0` means there. Left to it rather than pre-empted with a generic + * refusal. + */ + it('leaves a blank paginating limit to the pager, which words it better', () => { + expect(() => buildRequest('listWorkflows', [], { limit: '' }, WORKSPACE)).not.toThrow() + }) + it('rejects a missing required flag', () => { expect(() => buildRequest('upsertTableRow', ['t'], {}, WORKSPACE)).toThrow( '--data is required' diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 98226b57130..4c20877af38 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -39,7 +39,9 @@ export function isProfileWorkspacePath(commandSpec: CommandSpec, param: string): * `execute.ts` from `options.ts` would close a module cycle — `execute.ts` * already reads `DEFAULT_LIMIT` from `options.ts`. */ -export function cursorSlot(operationSpec: OperationSpec): 'query' | 'body' | null { +export function cursorSlot( + operationSpec: Pick +): 'query' | 'body' | null { if (operationSpec.query && 'cursor' in operationSpec.query) return 'query' if (operationSpec.body && 'cursor' in operationSpec.body) return 'body' return null @@ -479,6 +481,14 @@ export function buildRequest( const body: Record = {} const headers: Record = {} + /** + * On a paginating operation `limit` is the walk size rather than a filter, + * and the pager reads it from the flags itself — including refusing a blank + * one, in wording that says what `0` means there. Left to it, so the caller + * gets that message instead of the generic refusal below. + */ + const paginatedLimit = cursorSlot(spec) !== null + for (const slot of ['query', 'body', 'headers'] as const) { for (const [field, descriptor] of Object.entries(spec[slot] ?? {})) { const flag = flagSpecFor(operation, field) @@ -498,6 +508,26 @@ export function buildRequest( // typing the flag — including typing the server's own default back — still // decides. It is validated like any other value, enum choices included. const raw = provided ?? flag.requestDefault + + /** + * A blank filter is a mistake, and every v2 JSON route says so + * (`rejectBlankQueryValues`). The CLI never let one reach the wire: the + * URL builder skips an empty value, so `logs list --status ""` searched + * everything and answered `0`, a wider result set presented as an answer. + * Refused here, before the request, the way an empty list entry and an + * empty path parameter already are. Scoped to the query, because an empty + * body string is meaningful — it clears a description. + * + * Read from what the caller typed rather than from the coerced value, + * because coercion erases the blank on a numeric field: `Number('')` is + * `0`, so `--max-cost ""` reached the wire as a real "costing at most + * nothing" filter that a check on the coerced value cannot see. An + * explicit `--max-cost 0` is a value the caller chose and is still sent. + */ + if (slot === 'query' && raw === '' && !(field === 'limit' && paginatedLimit)) { + throw new SimApiError(`--${flagName} cannot be empty`, 0) + } + const value = coerce(raw ?? undefined, descriptor, flag, flagName) if (value === undefined) { @@ -512,19 +542,6 @@ export function buildRequest( continue } - /** - * A blank filter is a mistake, and every v2 JSON route says so - * (`rejectBlankQueryValues`). The CLI never let one reach the wire: the - * URL builder skips an empty value, so `logs list --status ""` searched - * everything and answered `0`, a wider result set presented as an answer. - * Refused here, before the request, the way an empty list entry and an - * empty path parameter already are. Scoped to the query, because an empty - * body string is meaningful — it clears a description. - */ - if (slot === 'query' && value === '') { - throw new SimApiError(`--${flagName} cannot be empty`, 0) - } - if (slot === 'query') query[field] = asQueryValue(value) // A header is a wire string: the contracts declare only string headers, // and anything else would reach `fetch` as `[object Object]`. diff --git a/packages/sim-cli/src/runtime/result.test.ts b/packages/sim-cli/src/runtime/result.test.ts index 06448190b74..d9e9412c084 100644 --- a/packages/sim-cli/src/runtime/result.test.ts +++ b/packages/sim-cli/src/runtime/result.test.ts @@ -351,6 +351,62 @@ describe('a truncation the response states inside its payload', () => { expect(read()).toBe('') }) + it.each(['notTruncated', 'unTruncated', 'nonTruncated', 'neverTruncated', 'isNotTruncated'])( + 'says nothing for the negated spelling %s', + (flag) => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { [flag]: true } }) + + expect(read()).toBe('') + } + ) + + /** + * The flag pattern, not the negation veto, is what rejects this: `was not + * truncated` carries neither the `Not`/`Un` casing the veto looks for nor the + * unbroken `Truncated` shape the pattern demands. Asserted separately + * so loosening the pattern to a bare `truncated` substring goes red here + * rather than silently leaning on the veto to cover it. + */ + it('says nothing for a key that only resembles the flag', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { 'was not truncated': true } }) + + expect(read()).toBe('') + }) + + /** + * The bound the scan is built on: `data` is descended only while it is an + * object, because a page's `data` is the rows and a row's keys are the + * caller's own. A column literally named `truncated` is a value in somebody's + * table, not a statement by the API, and reading it as one would warn about a + * clip that never happened on every row that ever holds `true`. + */ + it('ignores a row column named like the flag, because rows are not the envelope', () => { + const read = captureStderr() + const rows = [{ id: 'a', truncated: true }] + + renderPage('json', rows, {}, { data: rows }) + + expect(read()).toBe('') + expect(JSON.parse(logged.join('\n'))).toEqual(rows) + }) + + /** + * The other half of the same bound: the scan stops at `data`. A flag nested + * below it is not read, so a future "scan deeper" change has to face this + * test and the row-column case above together. + */ + it('does not descend past data', () => { + const read = captureStderr() + + renderResult('readFileText', 'json', {}, {}, {}, { data: { file: { truncated: true } } }) + + expect(read()).toBe('') + }) + it('leaves yaml a bare payload, with the note on stderr', () => { const read = captureStderr() diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 8bb27957037..a7e8259d658 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -324,13 +324,15 @@ function writePageNote(spec: CommandSpec, envelope: unknown): void { const TRUNCATION_FLAG = /^truncated$|^[A-Za-z0-9]+Truncated$/ /** - * Spellings whose `Truncated` suffix is negated, and so state the opposite. + * Negating prefixes whose `Truncated` suffix states the opposite. * * A bare `Truncated$` match also accepts `notTruncated` and `isNotTruncated`, - * where `true` means the answer is whole — the one thing this note must never - * turn into is a warning about a clip that did not happen. + * where `true` means the answer is whole, and a note about a clip that did not + * happen is the worst thing this can print. These four prefixes are the + * spellings worth anticipating rather than a decision procedure for English — + * a field negated some other way slips through and has to be added here. */ -const NEGATED_TRUNCATION_FLAG = /^(?:not|un)Truncated$|(?:Not|Un)Truncated$/ +const NEGATED_TRUNCATION_FLAG = /^(?:not|un|non|never)Truncated$|(?:Not|Un|Non|Never)Truncated$/ /** The flags one object raised, in the spelling the wire used. */ function truncationFlags(container: unknown): string[] { From 5584c00906aa5cd96474f67cdabcf8226eec941a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 26 Aug 2026 14:56:50 -0700 Subject: [PATCH 15/15] fix: close a credential-misdirection path this branch had opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the endpoint normalizer trim handled whitespace around a value but not a control character inside one, and the URL parser removes those from anywhere in its input — so a value that reads as one host could resolve to another, and the profile's key went with it. The flag and environment paths never touch the config writer, so its guard did not cover this. The normalizer now refuses the same character set the writer does, which also keeps the invariant that nothing it blesses can be refused by the write that stores it. Comparing the parsed URL back against its input was the alternative and is wrong: the parser rewrites percent-encoding, case, internationalized hosts and default ports, so legitimate endpoints would be refused. The blank-query guard tested for exactly empty, so a whitespace-only value still reached the wire — as a real zero on a numeric filter, an explicit false on a boolean one, and as an encoded space the server then rejected. It now refuses any value that is blank once trimmed, while a body string keeps its meaning, an explicit zero still sends, and a value with content around its whitespace is passed through untouched rather than trimmed. A graph-id conflict reported 409 on the v2 route and fell through the older persistence wrapper as an unclassified 500. That wrapper now classifies orchestration failures through the cause chain, which also fixes a pre-existing case where a workflow archived between authorization and the locked read reported 500 rather than 404. Persisting a chat turn claimed its row by id alone, so a conversation soft-deleted mid-turn still received the messages and was bumped back up the list. It now requires a live row. A turn whose caller hung up after the model had already answered persisted nothing, though the work was done and billed; it now persists and still reports the connection as closed. An empty workspace id from the login response was read as no workspace at all. A published description still promised a language-tag standard the schema does not enforce. The test asserting that a turn is stored before the final event drained the whole response first, so it held whichever order the code used. It now reads the stream incrementally and fails if the write moves after the event. --- apps/docs/openapi-v2-knowledge.json | 2 +- apps/sim/app/api/v2/chat/route.test.ts | 164 +++++++++++++++++- apps/sim/app/api/v2/chat/route.ts | 28 ++- apps/sim/lib/api/contracts/v2/knowledge.ts | 2 +- .../lib/copilot/chat/messages-store.test.ts | 40 ++++- apps/sim/lib/copilot/chat/messages-store.ts | 15 +- .../persistence/save-normalized-state.ts | 25 +++ .../save-workflow-normalized-state.test.ts | 56 ++++++ packages/sim-cli/src/commands/auth.test.ts | 27 +++ packages/sim-cli/src/commands/auth.ts | 15 +- packages/sim-cli/src/config/profile.test.ts | 37 ++++ packages/sim-cli/src/config/profile.ts | 21 +++ packages/sim-cli/src/runtime/request.test.ts | 35 +++- packages/sim-cli/src/runtime/request.ts | 14 +- 14 files changed, 457 insertions(+), 24 deletions(-) diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index c9c1409603e..d433c0ea73b 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6843,7 +6843,7 @@ "enum": ["default", "plain", "markdown", "code"] }, "lang": { - "description": "Optional document language, as a BCP-47 tag such as `en` or `en-US`.", + "description": "Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.", "type": "string", "maxLength": 35, "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 823e81299e0..945e2e8d564 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -3,6 +3,8 @@ */ import { createMockRequest } from '@sim/testing' +import { sleep } from '@sim/utils/helpers' +import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -130,6 +132,9 @@ const personalAuth = { const SERVER_ISSUED_CHAT_ID = 'chat-server-1' const OWNED_CONVERSATION_ID = '11111111-1111-4111-8111-111111111111' +/** How long a stream read may stay pending before it counts as "nothing more yet". */ +const IDLE_STREAM_MS = 20 + function chatRow(id: string) { return { id, userId: 'user-1', workspaceId: 'workspace-1', workflowId: null, type: 'mothership' } } @@ -148,6 +153,79 @@ function callChat(body: Record, headers: Record return POST(req, { params: Promise.resolve({}) }) } +/** + * Same call, but over a request whose signal the test controls — the only way + * to reproduce a caller that hangs up while the turn is still running. + */ +function callChatWithSignal( + body: Record, + signal: AbortSignal, + headers: Record = {} +) { + const req = new NextRequest(new URL('http://localhost:3000/api/v2/chat'), { + method: 'POST', + headers: new Headers({ + 'Content-Type': 'application/json', + 'X-API-Key': 'test-key', + ...headers, + }), + body: JSON.stringify(body), + signal, + }) + return POST(req, { params: Promise.resolve({}) }) +} + +/** + * Read an NDJSON response incrementally. `drain()` returns the events that have + * already reached the caller and stops as soon as the producer goes quiet; + * `rest()` reads to the end. A pending read is held across calls so no chunk is + * dropped between the two. + */ +function readNdjsonStream(response: Response) { + const reader = response.body!.getReader() + const decoder = new TextDecoder() + let buffered = '' + let pending: Promise> | null = null + + const parse = (): Array> => { + const lines = buffered.split('\n') + buffered = lines.pop() ?? '' + return lines.filter((line) => line.trim().length > 0).map((line) => JSON.parse(line)) + } + + const step = async (): Promise<'idle' | 'done' | 'chunk'> => { + pending ??= reader.read() + const settled = await Promise.race([ + pending.then((result) => ({ result })), + sleep(IDLE_STREAM_MS).then(() => null), + ]) + if (!settled) return 'idle' + pending = null + if (settled.result.done) return 'done' + buffered += decoder.decode(settled.result.value, { stream: true }) + return 'chunk' + } + + return { + async drain() { + const events: Array> = [] + for (;;) { + const state = await step() + events.push(...parse()) + if (state !== 'chunk') return events + } + }, + async rest() { + const events: Array> = [] + for (;;) { + const state = await step() + events.push(...parse()) + if (state === 'done') return events + } + }, + } +} + async function readNdjsonEvents(response: Response): Promise>> { const raw = await response.text() return raw @@ -484,16 +562,96 @@ describe('POST /api/v2/chat', () => { ]) }) - it('persists the turn on the NDJSON path too, before the final event', async () => { + it('persists the turn on the NDJSON path before the final event reaches the caller', async () => { + // Hold the transcript write open and watch the wire: draining the whole + // response first would pass just as happily with the write moved after the + // final event, so the write is gated and the stream read incrementally. + let persistEntered: () => void = () => {} + const persistInFlight = new Promise((resolve) => { + persistEntered = resolve + }) + let releasePersist: () => void = () => {} + mockPersistCopilotChatTurn.mockImplementation(() => { + persistEntered() + return new Promise((resolve) => { + releasePersist = resolve + }) + }) + const response = await callChat( { workspaceId: 'workspace-1', message: 'hi' }, { accept: 'application/x-ndjson' } ) - const events = await readNdjsonEvents(response) + const stream = readNdjsonStream(response) + + await persistInFlight + const beforeRelease = await stream.drain() + expect(beforeRelease.map((event) => event.type)).not.toContain('final') + + releasePersist() + const afterRelease = await stream.rest() + expect(afterRelease.at(-1)?.type).toBe('final') expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) expect(mockPersistCopilotChatTurn.mock.calls[0][1]).toHaveLength(2) - expect(events.at(-1)?.type).toBe('final') + }) + + it('persists a completed turn whose caller hung up, and still reports it as client-closed', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return successResult + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal + ) + + // The model already ran and was billed, so the reply is written to the + // conversation it belongs to — but the caller is gone, and the status the + // route reports says exactly that. + expect(response.status).toBe(499) + const body = await response.json() + expect(body.error.code).toBe('CLIENT_CLOSED_REQUEST') + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + expect(mockPersistCopilotChatTurn.mock.calls[0][0]).toBe(SERVER_ISSUED_CHAT_ID) + }) + + it('persists a completed turn whose NDJSON caller hung up, and still ends in an abort event', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return successResult + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal, + { accept: 'application/x-ndjson' } + ) + const events = await readNdjsonEvents(response) + + const last = events.at(-1) as { type: string; error?: string } + expect(last.type).toBe('error') + expect(last.error).toBe('Chat request aborted') + expect(mockPersistCopilotChatTurn).toHaveBeenCalledTimes(1) + }) + + it('persists nothing for a failed run whose caller hung up', async () => { + const controller = new AbortController() + mockRunHeadlessCopilotLifecycle.mockImplementation(async () => { + controller.abort() + return { success: false, error: 'model exploded' } + }) + + const response = await callChatWithSignal( + { workspaceId: 'workspace-1', message: 'hi' }, + controller.signal + ) + + expect(response.status).toBe(499) + expect(mockPersistCopilotChatTurn).not.toHaveBeenCalled() }) it('persists nothing when the run fails, so no question is stored without its answer', async () => { diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 4a30f2ec7be..7c5f097f068 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -198,11 +198,10 @@ export const POST = withRouteHandler( * resolved. Without it a `sim chat` turn leaves a titled conversation * that opens to an empty transcript in the web Chat list. * - * By the time this runs the turn has completed and been billed, and a - * streamed reply has already reached the caller, so a write failure is - * logged and the successful response still stands. The write is one - * transaction, so that failure leaves the transcript empty rather than - * showing the question without the answer. + * By the time this runs the turn has completed and been billed, so a + * write failure is logged and the response the caller gets is unchanged. + * The write is one transaction, so that failure leaves the transcript + * empty rather than showing the question without the answer. */ const persistTurn = async (result: OrchestratorResult): Promise => { try { @@ -355,6 +354,13 @@ export const POST = withRouteHandler( }) allowExplicitAbort = false + // Persist before the cancellation check: the turn ran and was + // billed, so the reply belongs in the transcript even when the + // caller stopped listening — that is the only place it survives. + if (result.success) { + await persistTurn(result) + } + if (lifecycleAbortController.signal.aborted) { send({ type: 'error', error: 'Chat request aborted' }) return @@ -373,8 +379,6 @@ export const POST = withRouteHandler( return } - await persistTurn(result) - send({ type: 'final', data: buildChatResultPayload(result, chatId, integrationTools), @@ -428,6 +432,14 @@ export const POST = withRouteHandler( const result = await runLifecycle() allowExplicitAbort = false + // Persist before the cancellation check: the turn ran and was billed, + // so the reply belongs in the transcript even when the caller stopped + // listening — that is the only place it survives. The cancellation + // check still decides the status the caller receives. + if (result.success) { + await persistTurn(result) + } + if (lifecycleAbortController.signal.aborted || req.signal.aborted) { reqLogger.info('Chat request aborted after lifecycle completion') return v2Error('CLIENT_CLOSED_REQUEST', 'Chat request aborted') @@ -438,8 +450,6 @@ export const POST = withRouteHandler( return v2Error('INTERNAL_ERROR', result.error || 'Chat request failed') } - await persistTurn(result) - return v2Data(buildChatResultPayload(result, chatId, integrationTools)) } finally { allowExplicitAbort = false diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 332d7fc64f7..a30fc2bd912 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -490,7 +490,7 @@ const v2KnowledgeDocumentProcessingOptionsSchema = lang: knowledgeDocumentUploadMetadataSchema.shape.processingOptions .unwrap() .shape.lang.describe( - 'Optional document language, as a BCP-47 tag such as `en` or `en-US`.' + 'Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.' ), }) .strict() diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/copilot/chat/messages-store.test.ts index 5a8b1155c4b..a620ce64757 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/copilot/chat/messages-store.test.ts @@ -1,10 +1,11 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { appendCopilotChatMessages, + persistCopilotChatTurn, replaceCopilotChatMessages, } from '@/lib/copilot/chat/messages-store' import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' @@ -231,4 +232,41 @@ describe('messages-store', () => { expect(JSON.stringify(lastValuesRows())).not.toContain('huge') }) }) + + describe('persistCopilotChatTurn', () => { + it('claims the chat row by id AND liveness, so a soft-deleted chat matches nothing', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ model: 'claude-sonnet-4-5' }]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + // The chain mock ignores predicates, so the predicate itself is the + // assertion: without the liveness term the update still matches an + // archived row and the turn lands in a conversation the user deleted. + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: schemaMock.copilotChats.id, right: 'chat-1' }, + { type: 'isNull', column: schemaMock.copilotChats.deletedAt }, + ], + }) + }) + + it('writes the transcript with the chat model when the row is still live', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ model: 'claude-sonnet-4-5' }]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + const rows = lastValuesRows() + expect(rows.map((r) => r.messageId)).toEqual(['msg-user-1', 'msg-asst-1']) + expect(rows[0].model).toBe('claude-sonnet-4-5') + }) + + it('writes nothing when the claim matches no row', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await persistCopilotChatTurn('chat-1', [userMsg, assistantMsg]) + + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/copilot/chat/messages-store.ts index 386f8e1e11e..fdf09b7aa3e 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/copilot/chat/messages-store.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' -import { and, eq, notInArray, sql } from 'drizzle-orm' +import { and, eq, isNull, notInArray, sql } from 'drizzle-orm' import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' @@ -87,8 +87,15 @@ export async function appendCopilotChatMessages( * turn run without that surface would leave a chat that opens to nothing. * * Both messages are written in a single transaction, so a failure leaves the - * transcript untouched rather than showing a question with no answer. Does - * nothing when the chat no longer exists; throws on a write failure. + * transcript untouched rather than showing a question with no answer. + * + * The chat row is claimed under the same liveness predicate the accessible-chat + * loaders use, so a chat soft-deleted while the turn was running receives + * nothing: the update matches no row and the transaction returns having written + * neither the transcript nor the recency bump. Dropping the turn is right here + * because the user deleted the conversation after asking — resurrecting it with + * a reply would undo that deletion, and the caller still has its reply in the + * response. Throws on a write failure. */ export async function persistCopilotChatTurn( chatId: string, @@ -98,7 +105,7 @@ export async function persistCopilotChatTurn( const [updated] = await tx .update(copilotChats) .set({ updatedAt: new Date() }) - .where(eq(copilotChats.id, chatId)) + .where(and(eq(copilotChats.id, chatId), isNull(copilotChats.deletedAt))) .returning({ model: copilotChats.model }) if (!updated) return await appendCopilotChatMessages(chatId, messages, { chatModel: updated.model ?? null }, tx) diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index 4d921ee793f..a1c2a036513 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -10,6 +10,11 @@ import { type WorkflowStateContractOutput, workflowStateSchema, } from '@/lib/api/contracts/workflows' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { replaceWorkflowNormalizedState, @@ -115,6 +120,26 @@ export async function saveWorkflowNormalizedState(params: { details: error.detail, } } + /** + * The shared write classifies its own caller-fixable refusals — a graph id + * another workflow already owns is a `conflict`, a workflow archived since + * the authorization check is a `not_found`. Reading them here is what keeps + * this door's statuses identical to the ones `replaceWorkflowState` returns + * for the same refusal, rather than collapsing them into the caller's + * generic 500. Read through the cause chain because the throw happens + * inside the transaction callback, which drizzle wraps. + */ + const classified = asOrchestrationError(error) + if (classified) { + return { + success: false, + status: statusForOrchestrationError(classified.code), + error: messageForOrchestrationError( + { error: classified.message, errorCode: classified.code }, + 'Failed to save workflow state' + ), + } + } throw error } diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts index f648e5fc0ac..99c1dde6531 100644 --- a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -28,6 +28,7 @@ vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { }) vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { WorkflowStatePersistenceError } from '@/lib/workflows/persistence/replace-normalized-state' import { saveWorkflowNormalizedState } from '@/lib/workflows/persistence/save-normalized-state' @@ -160,6 +161,61 @@ describe('saveWorkflowNormalizedState', () => { expect(mocks.notify).not.toHaveBeenCalled() }) + it('reports a claimed graph id as 409 carrying the ids to change', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'Block ids already used by another workflow: block-1, block-2' + ) + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 409, + error: 'Block ids already used by another workflow: block-1, block-2', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('reports a workflow archived since the authorization check as 404', async () => { + mocks.replace.mockRejectedValue(new OrchestrationError('not_found', 'Workflow not found')) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 404, + error: 'Workflow not found', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('classifies through the wrapper drizzle puts around a throw inside the transaction', async () => { + const wrapped = new Error('insert into "workflow_blocks" ...', { + cause: new OrchestrationError( + 'conflict', + 'Edge ids already used by another workflow: edge-1' + ), + }) + mocks.replace.mockRejectedValue(wrapped) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 409, + error: 'Edge ids already used by another workflow: edge-1', + }) + }) + + it('hides the text of an unclassified orchestration failure behind the generic wording', async () => { + mocks.replace.mockRejectedValue( + new OrchestrationError('internal', 'insert into "workflow_blocks" values ($1, $2)') + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 500, + error: 'Failed to save workflow state', + }) + }) + it('propagates an unclassified fault rather than turning it into a status', async () => { mocks.replace.mockRejectedValue(new Error('pool exhausted')) diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index f62539bc925..1ce95179ffb 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -346,6 +346,33 @@ describe('login command', () => { }) expect(console.log).toHaveBeenCalledWith(expect.stringContaining('no default workspace')) }) + + it('refuses an empty workspace id instead of storing it as no workspace', async () => { + setInteractive(false) + mocks.profileFrom.mockReturnValue({ + name: 'default', + endpoint: 'https://sim.ai', + apiKey: null, + workspaceId: 'ws_old', + output: 'table', + sources: { + endpoint: 'default', + apiKey: 'unset', + workspaceId: 'config', + output: 'default', + }, + }) + mocks.pollForKey.mockResolvedValue({ + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: '', + }) + + await expect(login()).rejects.toThrow('Empty workspace id from the login response.') + expect(mocks.writeConfigProfile).not.toHaveBeenCalled() + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) }) describe('profiles command', () => { diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 4422be3a226..d481a176817 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -332,11 +332,20 @@ export function loginCommand(): Command { // making them look up its id afterwards would waste the one moment the // answer was already on screen. It arrives off the wire, so it is // checked before either file is touched. + // + // Absence is the whole of "no workspace" here, and it is a legitimate + // outcome — a personal key with nothing selected in the browser. A + // *present* value is a workspace id, so every one of them goes to + // `normalizeWorkspaceId` to be accepted or refused by name. Testing + // truthiness instead let an empty string through the absent branch, so + // a malformed response was quietly stored as "no workspace" rather than + // reported. const settings: Record = { endpoint: profile.endpoint, - workspace: key.workspaceId - ? normalizeWorkspaceId(key.workspaceId, 'the login response') - : null, + workspace: + key.workspaceId == null + ? null + : normalizeWorkspaceId(key.workspaceId, 'the login response'), } requireStorableKey(key.apiKey) diff --git a/packages/sim-cli/src/config/profile.test.ts b/packages/sim-cli/src/config/profile.test.ts index cfdf6c5a0b9..81a702ad9c2 100644 --- a/packages/sim-cli/src/config/profile.test.ts +++ b/packages/sim-cli/src/config/profile.test.ts @@ -256,6 +256,43 @@ describe('profile resolution', () => { ).not.toThrow() }) + it('refuses an endpoint carrying a control character, from every source', () => { + // The URL parser deletes tabs and line breaks from anywhere in its input + // before parsing, so the host a reader sees in the string need not be the + // host the request reaches — and the request carries the API key. Trimming + // only reaches the ends, so the normalizer has to refuse the whole set. + for (const endpoint of [ + 'https://www.sim.ai\n@other.invalid', + 'https://www.sim.ai\r@other.invalid', + 'https://www.sim.ai\t@other.invalid', + 'https://www.sim.ai\u0000@other.invalid', + 'https://www.sim.ai\u2028@other.invalid', + ]) { + expect(() => resolveProfile({ endpoint })).toThrow( + 'An endpoint cannot contain line breaks or control characters.' + ) + // The rejected text is echoed back with the control characters redacted, + // so an error message cannot become an escape-sequence delivery vehicle. + expect(() => resolveProfile({ endpoint })).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from flag.' + ) + } + + process.env.SIM_ENDPOINT = 'https://www.sim.ai\t@other.invalid' + expect(() => resolveProfile()).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from env.' + ) + + Reflect.deleteProperty(process.env, 'SIM_ENDPOINT') + // A tab survives the config reader — `.` matches it, unlike a line break — + // so a hand-edited file can hold one even though the writer refuses to + // produce it, and the read path has to refuse it too. + writeFileSync(configPath(), '[default]\nendpoint = https://www.sim.ai\t@other.invalid\n') + expect(() => resolveProfile()).toThrow( + 'Invalid endpoint "https://www.sim.ai @other.invalid" from config.' + ) + }) + it('fails fast on an endpoint Node cannot parse, naming the source', () => { expect(() => resolveProfile({ endpoint: 'not-a-url' })).toThrow( 'Invalid endpoint "not-a-url" from flag. Use an absolute URL, e.g. https://www.sim.ai or http://localhost:3000' diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 1d79d0c6fb4..1c274811b64 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -318,6 +318,27 @@ export function normalizeEndpoint(endpoint: string, source: string): string { // `https://sim.ai//api/v2/...`, which some proxies 404 rather than normalize. const trimmed = endpoint.trim().replace(/\/+$/, '') + // Trimming only reaches the ends, and a control character in the middle is + // the one that matters: the URL parser deletes tabs and line breaks from + // anywhere in its input before parsing it, so a string whose visible text + // names one host can resolve to a different authority — and the resolved one + // is where the API key is sent. Refusing the whole forbidden set here means + // the endpoint this function blesses is the endpoint every later parse sees. + // + // A character check rather than parse-and-compare: comparing `trimmed` + // against `parsed.href` would also reject legitimate endpoints, because the + // parser rewrites percent-encoding, lowercases the scheme and host, + // punycodes an IDN, and drops a default port. The forbidden set is instead a + // strict superset of the characters the parser silently removes, so it is + // exact for this hazard — and it is `FORBIDDEN_IN_VALUE`, the same constant + // the config writer enforces, so a value accepted here can never be refused + // by the write that stores it. + if (FORBIDDEN_IN_VALUE.test(trimmed)) { + throw new ProfileConfigError( + `Invalid endpoint "${endpoint.replace(FORBIDDEN_IN_VALUE_GLOBAL, ' ')}" from ${source}. An endpoint cannot contain line breaks or control characters.` + ) + } + let parsed: URL try { parsed = new URL(trimmed) diff --git a/packages/sim-cli/src/runtime/request.test.ts b/packages/sim-cli/src/runtime/request.test.ts index 3748797d7f4..9808aff4498 100644 --- a/packages/sim-cli/src/runtime/request.test.ts +++ b/packages/sim-cli/src/runtime/request.test.ts @@ -127,6 +127,10 @@ describe('buildRequest', () => { expect(buildRequest('updateWorkflow', ['wf_1'], { description: '' }, WORKSPACE).body).toEqual({ description: '', }) + // Blank-scoped to the query on both spellings: a body string is the value. + expect(buildRequest('updateWorkflow', ['wf_1'], { description: ' ' }, WORKSPACE).body).toEqual({ + description: ' ', + }) }) describe('failures, all before any network call', () => { @@ -172,14 +176,43 @@ describe('buildRequest', () => { ) }) + /** + * A quoted space is invisible in a shell and reached the wire as every + * blank the empty string did — `--max-cost " "` as a real `0` ceiling, + * `--deployed-only " "` as an explicit `false`, `--status " "` as the + * `%20` the route reads as blank and answers `400`. + */ + it('rejects a whitespace-only query filter, which is blank on the wire too', () => { + expect(() => buildRequest('listLogs', [], { status: ' ' }, WORKSPACE)).toThrow( + '--status cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { maxCost: ' ' }, WORKSPACE)).toThrow( + '--max-cost cannot be empty' + ) + expect(() => buildRequest('listLogs', [], { minDurationMs: '\t' }, WORKSPACE)).toThrow( + '--min-duration-ms cannot be empty' + ) + expect(() => buildRequest('listWorkflows', [], { deployedOnly: ' ' }, WORKSPACE)).toThrow( + '--deployed-only cannot be empty' + ) + }) + + /** Only an all-whitespace value is blank; the surrounding spaces are the caller's. */ + it('still sends a query value that has content around its whitespace', () => { + expect(buildRequest('listLogs', [], { workflowName: ' q3 ' }, WORKSPACE).query).toMatchObject( + { workflowName: ' q3 ' } + ) + }) + /** * A paginating `limit` is the walk size, not a filter, and the pager reads * it from the flags itself — refusing a blank one in wording that says what * `0` means there. Left to it rather than pre-empted with a generic - * refusal. + * refusal, whitespace included: the pager trims before it decides. */ it('leaves a blank paginating limit to the pager, which words it better', () => { expect(() => buildRequest('listWorkflows', [], { limit: '' }, WORKSPACE)).not.toThrow() + expect(() => buildRequest('listWorkflows', [], { limit: ' ' }, WORKSPACE)).not.toThrow() }) it('rejects a missing required flag', () => { diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 4c20877af38..d3d611a54f9 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -523,8 +523,20 @@ export function buildRequest( * `0`, so `--max-cost ""` reached the wire as a real "costing at most * nothing" filter that a check on the coerced value cannot see. An * explicit `--max-cost 0` is a value the caller chose and is still sent. + * + * Blank is `trim()`-empty rather than exactly empty, matching both the + * route — `blankQueryValueValidationError` reads `?status=%20` as blank — + * and the list flag beside it, which trims each entry before refusing it. + * A quoted space is invisible in a shell and read as every blank the + * empty string did: `--max-cost " "` as `0`, `--deployed-only " "` as an + * explicit `false`, `--status " "` as a `%20` the server answers `400`. */ - if (slot === 'query' && raw === '' && !(field === 'limit' && paginatedLimit)) { + if ( + slot === 'query' && + typeof raw === 'string' && + raw.trim() === '' && + !(field === 'limit' && paginatedLimit) + ) { throw new SimApiError(`--${flagName} cannot be empty`, 0) }