diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts index 06109708de..e61015bba7 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts @@ -106,6 +106,13 @@ const CATALOG = [ path: '/w/{workspace}/scripts/get/p/{path}', method: 'GET' }, + { + name: 'getAppByPath', + description: 'Get app by path', + instructions: 'Returns the app source', + path: '/w/{workspace}/apps/get/p/{path}', + method: 'GET' + }, { name: 'deleteScriptByHash', description: 'Delete a script by hash', @@ -260,12 +267,12 @@ describe('call_api_get', () => { }) it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => { - for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) { + for (const name of ['getScriptByPath', 'getResource', 'getSchedule', 'getAppByPath']) { const result = await run('call_api_get', { name }) expect(result.success).toBe(false) expect(result.error).toContain('read_workspace_item') } - for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules']) { + for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules', 'listApps']) { const result = await run('call_api_get', { name }) expect(result.success).toBe(false) expect(result.error).toContain('list_workspace_items') @@ -273,6 +280,9 @@ describe('call_api_get', () => { const search = await run('search_api_endpoints', { query: 'get script' }) expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath') + // getAppByPath would hand the model the whole app source, so it must not even surface. + const appSearch = await run('search_api_endpoints', { query: 'get app' }) + expect(appSearch.matches.map((m: any) => m.name)).not.toContain('getAppByPath') }) it('refuses the worker and data-metric reads, pointing at their dedicated tools', async () => { diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts index 42c68c6ebf..05eea477a5 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts @@ -32,6 +32,11 @@ const COVERED_ENDPOINTS: Record = { 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', getSchedule: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', + // getAppByPath returns the entire app source — every file, every runnable's + // script, lock and schema — where read_workspace_item returns paths and sizes. + getAppByPath: + 'read_workspace_item (app metadata only; read_app_file for contents. Reads your draft when one exists; pass version: "deployed" for the deployed state)', + listApps: 'list_workspace_items (it includes your drafts)', listScripts: 'list_workspace_items (it includes your drafts)', listFlows: 'list_workspace_items (it includes your drafts)', listResource: 'list_workspace_items (it includes your drafts)', diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 876e178e31..7f2a1ff3d0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -259,6 +259,11 @@ vi.mock('$lib/gen', async () => { WorkerService: wrapService(actual.WorkerService, { listWorkers: vi.fn(async () => []) }), + // Guests off by default, as a fresh workspace has them. + WorkspaceService: wrapService(actual.WorkspaceService, { + getGuestUsage: vi.fn(async () => ({ available: false, instance_enabled: false })), + getPublicSettings: vi.fn(async () => ({ guest_access_enabled: false })) + }), FolderService: wrapService(actual.FolderService, { createFolder: vi.fn(async () => 'created') }), @@ -404,7 +409,8 @@ import { ScriptService, UserService, VariableService, - WorkerService + WorkerService, + WorkspaceService } from '$lib/gen' import { devopsRole, superadmin, userStore, usersWorkspaceStore } from '$lib/stores' import { processSecretArgs } from '$lib/components/secretArgUtils' @@ -3590,6 +3596,26 @@ describe('global AI tools', () => { }) }) + // The draft carries the deployed policy from the fork, so nothing is fetched to answer. + it('reports exposure for an app that has a draft over it', async () => { + seedBackendDraft( + 'raw_app', + 'f/apps/drafted', + { + files: { '/src/App.tsx': 'x' }, + runnables: {}, + policy: { execution_mode: 'anonymous' } + } as any, + { workspace: WORKSPACE } + ) + + const read = await callGlobalTool('read_workspace_item', { + type: 'app', + path: 'f/apps/drafted' + }) + expect(JSON.parse(read)).toMatchObject({ isDraft: true, executionMode: 'anonymous' }) + }) + it('summarizes local raw app drafts in read_workspace_item', async () => { seedBackendDraft( 'raw_app', @@ -3705,6 +3731,91 @@ describe('global AI tools', () => { expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) + // A low-code app has a grid, not files and runnables, so every app tool here would + // otherwise report it as empty rather than say it is the wrong kind of app. + it('reports a low-code app without its contents, and refuses to act on it', async () => { + const lowCode = { + path: 'f/apps/legacy', + summary: 'legacy app', + raw_app: false, + value: { grid: [{ id: 'a', data: { type: 'buttoncomponent' } }] } + } as any + // ...Once per call: a persistent implementation would outlive this test and + // disarm the factory's "mock not configured" guard for the rest of the file. + for (let i = 0; i < 3; i++) vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(lowCode) + + // The read answers "this app is drag-and-drop" rather than throwing — but it must not + // summarize a grid as a file/runnable list, which reads as an empty app. + const read = JSON.parse( + await callGlobalTool('read_workspace_item', { type: 'app', path: 'f/apps/legacy' }) + ) + expect(read).toMatchObject({ type: 'app', path: 'f/apps/legacy', rawApp: false }) + expect(read).not.toHaveProperty('value') + + // The tools that would convert it to files and runnables still refuse: a staged draft + // would answer every later read in place of the app itself. + for (const [tool, args] of [ + ['read_app_file', { path: 'f/apps/legacy', file_path: '/index.tsx' }], + ['write_app_file', { path: 'f/apps/legacy', file_path: '/App.tsx', content: 'x' }] + ] as [string, any][]) { + const raw = await callGlobalTool(tool, args).catch((e) => String(e)) + expect(raw).toContain('low-code app') + } + expect(getBackendDraft('raw_app', 'f/apps/legacy', { workspace: WORKSPACE })).toBeUndefined() + }) + + // getAppByPath and listApps are refused by the catalog, so this read is the only way + // left to ask who may open a deployed app. Both kinds answer: a drag-and-drop app can + // be anonymous too, and no other tool here can inspect one. + it('reports who may open an app, whichever kind it is', async () => { + const readMode = async (app: any) => { + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce(app) + const read = await callGlobalTool('read_workspace_item', { type: 'app', path: app.path }) + return JSON.parse(read).executionMode + } + const guestApp = { + path: 'f/apps/code', + summary: 'Code app', + raw_app: true, + value: { files: {}, runnables: {} }, + policy: { execution_mode: 'guest' } + } + + vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ + available: true, + instance_enabled: true + } as any) + vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ + guest_access_enabled: true + } as any) + expect(await readMode(guestApp)).toBe('guest') + + expect( + await readMode({ + path: 'f/apps/builder', + summary: 'Builder app', + raw_app: false, + value: { grid: [] }, + policy: { execution_mode: 'anonymous' } + }) + ).toBe('anonymous') + + // The same app, with each switch crossed in turn: either one alone admits nobody, so + // reporting the mode bare would name an exposure the server refuses. Both off needs + // no case of its own — whichever half of the check were dropped, one of these two + // still catches it. + vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ + guest_access_enabled: true + } as any) + expect(await readMode(guestApp)).toContain('inert') + + vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ + available: true, + instance_enabled: true + } as any) + expect(await readMode(guestApp)).toContain('inert') + }) + it('reads raw app files without creating a draft', async () => { vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({ path: 'f/apps/report', @@ -4361,6 +4472,69 @@ describe('global AI tools', () => { expect(getBackendDraft('raw_app', 'f/apps/report', { workspace: WORKSPACE })).toBeUndefined() }) + // Deploying is what makes an app's runnables reachable, so it is the one moment the + // exposure is both true and known. + it('discloses who can open an app after a deploy', async () => { + const deployApp = async (path: string, policy: Record) => { + vi.mocked(AppService.existsApp).mockResolvedValueOnce(true) + vi.mocked(AppService.getAppByPath).mockResolvedValueOnce({} as any) + seedBackendDraft( + 'raw_app', + path, + { + summary: 'App', + files: { '/index.tsx': 'x' }, + runnables: {}, + data: { tables: [] }, + policy + }, + { workspace: WORKSPACE } + ) + return JSON.parse(await callGlobalTool('deploy_workspace_item', { type: 'app', path })) + } + + const open = await deployApp('f/apps/open', { + execution_mode: 'anonymous', + on_behalf_of: 'u/alice' + }) + expect(open.success).toBe(true) + expect(open.message).toContain('anyone with the URL, without logging in') + // The server decides what a deployed app runs as — it overwrites on_behalf_of for + // anyone outside the deployers group — so the note must not name an identity. + expect(open.message).not.toContain('u/alice') + + // Guest is a real widening only where the deployment, the instance and the + // workspace all admit guests; stored below that, it is inert. + vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ + available: true, + instance_enabled: true + } as any) + vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ + guest_access_enabled: true + } as any) + const guestOn = await deployApp('f/apps/guest', { execution_mode: 'guest' }) + expect(guestOn.success).toBe(true) + expect(guestOn.message).toContain('identity provider authenticates') + + // Each switch crossed in turn, because either one alone admits nobody and the note + // would then announce an exposure that does not exist. Both off needs no case of its + // own: whichever half of the check were dropped, one of these two still catches it. + vi.mocked(WorkspaceService.getPublicSettings).mockResolvedValueOnce({ + guest_access_enabled: true + } as any) + const instanceOff = await deployApp('f/apps/guest_inst_off', { execution_mode: 'guest' }) + expect(instanceOff.success).toBe(true) + expect(instanceOff.message).not.toContain('identity provider') + + vi.mocked(WorkspaceService.getGuestUsage).mockResolvedValueOnce({ + available: true, + instance_enabled: true + } as any) + const workspaceOff = await deployApp('f/apps/guest_ws_off', { execution_mode: 'guest' }) + expect(workspaceOff.success).toBe(true) + expect(workspaceOff.message).not.toContain('identity provider') + }) + it('forwards preserve_on_behalf_of when the deployed policy carries an on_behalf_of', async () => { // Without the flag the backend resets the policy's on_behalf_of to the // deploying user; this chat path has no on-behalf-of selector, so it must diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 7dfad3816f..2e68b74fe1 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -19,7 +19,8 @@ import { SqsTriggerService, VariableService, WebsocketTriggerService, - WorkerService + WorkerService, + WorkspaceService } from '$lib/gen' import { createTwoFilesPatch } from 'diff' import { deepEqual } from 'fast-equals' @@ -1421,8 +1422,9 @@ Flows: - Use patch_flow_json for structural flow edits and write_flow for full flow rewrites. Raw apps: -- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app, which you can list and read but not edit or deploy. Check it before offering to change an app. +- The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app. - read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents. +- A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. - Use write_app_file, patch_app_file, and delete_app_file for frontend files. - Use write_app_runnable and delete_app_runnable for backend runnables. - Use init_app only after confirming framework, path, and summary with the user. @@ -1528,6 +1530,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { summary: item.summary, value: summarizeAppValue(item.value as AppDraftValue), rawApp: item.rawApp, + executionMode: item.executionMode, isDraft: item.isDraft } } @@ -1824,13 +1827,28 @@ function getInlineRunnableContent( return { content: runnable.inlineScript?.content ?? '', runnable } } +// appSourceToDraftValue drops a low-code app's `grid`, so converting one here would stage a +// code-app draft at its path: a false picture of the app that every later read answers from, +// and a deploy the server then refuses. +async function getRawAppByPath(workspace: string, path: string): Promise { + const app = await AppService.getAppByPath({ workspace, path }) + // Only an explicit false: the draft-only branch of get_app carries no version, and so + // no `raw_app`, and must not read as low-code. + if (app.raw_app === false) { + throw new Error( + `"${path}" is a low-code app. This chat only edits code-based apps — open it in the app editor instead.` + ) + } + return app +} + async function loadAppValueForRead(path: string, workspace: string): Promise { const draft = await getGlobalDraft(workspace, 'app', path) if (draft && draft.value && typeof draft.value === 'object' && 'files' in draft.value) { return draft.value as AppDraftValue } - const app = await AppService.getAppByPath({ workspace, path }) + const app = await getRawAppByPath(workspace, path) return appSourceToDraftValue(app, app) } @@ -1840,7 +1858,7 @@ async function loadAppDraftValue(path: string, workspace: string): Promise = { } } +/** Whether the server would admit a guest here: the deployment has to support guests at + * all, and the instance and workspace switches both have to be on. `guest` is stored on + * an app even when none of that holds, so the mode alone never settles who can open it. + * `undefined` when a switch read fails — neither proven live nor proven inert. */ +async function guestAccessIsLive(workspace: string): Promise { + const [usage, settings] = await Promise.all([ + WorkspaceService.getGuestUsage({ workspace }).catch(() => undefined), + WorkspaceService.getPublicSettings({ workspace }).catch(() => undefined) + ]) + if (usage === undefined || settings === undefined) { + return undefined + } + return !!(usage.available && usage.instance_enabled && settings.guest_access_enabled) +} + +/** Who may open an app, from the mode stored on it. An app sits in `guest` mode whether + * or not anyone is admitted by it, so reporting the mode bare would say strangers can + * open an app that admits members only. Say it is inert rather than hide it, as the + * app's deploy settings do. */ +async function describeAppExposure( + workspace: string, + mode: string | undefined +): Promise { + if (mode !== 'guest' || (await guestAccessIsLive(workspace)) !== false) { + return mode + } + return 'guest, but inert: the instance or workspace admits no guest, so this app admits members only' +} + async function readWorkspaceItem( type: WorkspaceItemType, path: string, @@ -2036,6 +2083,19 @@ async function readWorkspaceItem( case 'app': { // Returns lightweight metadata only — file/runnable contents come via read_app_file. const app = await AppService.getAppByPath({ workspace, path }) + // A grid is not files and runnables: summarizing one reports an empty app. Name the + // kind instead. + const executionMode = await describeAppExposure(workspace, app.policy?.execution_mode) + if (app.raw_app === false) { + return { + type: 'app', + path: app.path, + summary: app.summary, + rawApp: false, + executionMode, + isDraft: false + } + } const value = appSourceToDraftValue(app) const metadata = summarizeAppValue(value) return { @@ -2044,6 +2104,7 @@ async function readWorkspaceItem( summary: value.summary, value: metadata as unknown as AppDraftValue, rawApp: app.raw_app, + executionMode, isDraft: false } } @@ -3468,7 +3529,17 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Read draft ${parsed.type} "${parsed.path}"` }) - return JSON.stringify(serializeWorkspaceItemForRead(draft), null, 2) + // The mode reported is the draft's, which is what deploying it will write. The + // deployed app's own mode is a different question, asked with version: + // "deployed", and must not be fetched here. + const executionMode = + parsed.type === 'app' + ? await describeAppExposure( + workspace, + (draft.value as AppDraftValue)?.policy?.execution_mode + ) + : undefined + return JSON.stringify(serializeWorkspaceItemForRead({ ...draft, executionMode }), null, 2) } toolCallbacks.setToolStatus(toolId, { @@ -7990,6 +8061,27 @@ async function deployDraft( `not working until they are.` } + // `policy` is the mode being written, so the exposure is stated from the value in + // hand. What a runnable runs as is the server's to decide, so the note names who + // can reach the app and never an identity. + if (policy.execution_mode === 'anonymous') { + deployNote = + `${deployNote ? `${deployNote} ` : ''}This app is deployed as anonymous: its ` + + `backend runnables are now reachable by anyone with the URL, without logging in. ` + + `Tell the user plainly what is now reachable and by whom.` + } else if (policy.execution_mode === 'guest') { + // Only where the guest door actually opens: below that, silence — a false note + // is worse than none. The standing cap is a live count no read settles, so the + // note says the door is open, not that every newcomer gets in. + if (await guestAccessIsLive(workspace)) { + deployNote = + `${deployNote ? `${deployNote} ` : ''}This app is deployed as guest: anyone ` + + `the instance's identity provider authenticates can now open it and run its ` + + `backend runnables, member of this workspace or not. Tell the user plainly ` + + `what is now reachable and by whom.` + } + } + toolCallbacks.setToolStatus(toolId, { content: `Bundling app "${path}"...` }) diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts index 61b2773ef8..3d922a3b72 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -143,6 +143,14 @@ export type WorkspaceItem = { * editor. The two are edited by disjoint tool sets, so the distinction has to * reach the model before it picks one. */ rawApp?: boolean + /** Apps only. Who may open the app — read from a draft, who may open it once + * that draft is deployed: `anonymous` is anyone with the URL and no login, + * `guest` anyone the identity provider authenticates, the rest a workspace + * member. A `guest` app whose instance or workspace admits + * no guest says so here, since the mode is stored either way. Reported per + * app, not in listings — a listing carrying it would make absence read as + * proof of no exposure. */ + executionMode?: string isDraft: boolean isLiveDraft?: boolean }