mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-22 08:02:40 +00:00
feat(ai-chat): disclose an app's exposure on deploy, refuse drag-and-drop apps (#11137)
* feat(ai-chat): disclose an app's exposure on deploy, refuse drag-and-drop apps Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * refactor(ai-chat): leave the app kind check at deploy to the server Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * feat(ai-chat): disclose a guest deploy where the instance admits guests Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * test(ai-chat): pin each guest switch in the deploy disclosure Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * docs(ai-chat): describe what a staged draft over a low-code app actually breaks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * feat(ai-chat): report who may open an app when reading it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * fix(ai-chat): report a guest app as inert where no guest is admitted Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * fix(ai-chat): report who may open an app that has a draft over it Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk * docs(ai-chat): say which mode a drafted app read reports Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018XFF7zKcuVqzvKDBTMiWHk --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6186a0645d
commit
85b36a2de5
@@ -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 () => {
|
||||
|
||||
@@ -32,6 +32,11 @@ const COVERED_ENDPOINTS: Record<string, string> = {
|
||||
'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)',
|
||||
|
||||
@@ -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<string, unknown>) => {
|
||||
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
|
||||
|
||||
@@ -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<AppWithLastVersion> {
|
||||
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<AppDraftValue> {
|
||||
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<Loade
|
||||
return { value: draft.value as AppDraftValue }
|
||||
}
|
||||
|
||||
const app = await AppService.getAppByPath({ workspace, path })
|
||||
const app = await getRawAppByPath(workspace, path)
|
||||
return { value: appSourceToDraftValue(app, app) }
|
||||
}
|
||||
|
||||
@@ -1974,6 +1992,35 @@ const triggerServices: Record<TriggerKind, TriggerService> = {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<boolean | undefined> {
|
||||
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<string | undefined> {
|
||||
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}"...`
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user