diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 6a72949c96..f2a9ec8dad 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -210,7 +210,7 @@ the diff first — `git diff --name-only main...HEAD` answers most of these. **Flip without asking** when it is self-contained: a single-file fix, test-only, docs-only, one call site, no new public surface. -Unattended (webmux oneshot) there is nobody to ask, so the judgement holds and the action +Unattended (a one-shot run) there is nobody to ask, so the judgement holds and the action degrades: flip the self-contained ones, and leave the rest at a clean draft with a line in the PR description saying why — `left in draft: adds a migration, wants a human look before ready`. Don't flip a wide-blast-radius change just because the round came back clean, and don't ask a diff --git a/AGENTS.md b/AGENTS.md index a315f1a67c..77b38c3267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,12 +55,23 @@ Open-source platform for internal tools, workflows, API integrations, background > defaults in this section apply only to a plain single checkout. **Discover the real > values before running anything** — see "Per-worktree ports and database" below. -**Check whether they are already running before starting anything.** In a webmux worktree -(`$WEBMUX_WORKTREE_PATH` is set) the backend and frontend are already up in sibling tmux panes — -use those, don't spawn your own. `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE" -'#{window_id}')" -F '#{pane_index} #{pane_current_command}'` shows what is running; read its log -with `tmux capture-pane`, and see `backend/AGENTS.md` to restart it with different cargo features. -A second server started in your own shell fights the first one for the port. The commands below +**Check whether they are already running before starting anything.** In a managed worktree the +backend and frontend are already up in sibling panes — use those, don't spawn your own. A second +server started in your own shell fights the first one for the port. + +**herdr** is the worktree manager; `$HERDR_ENV=1` marks one of its panes. `herdr pane list +--workspace "$HERDR_WORKSPACE_ID"` lists this worktree's panes — match on `cwd`, since the servers +run from `backend/` and `frontend/` — and `herdr pane read --source recent-unwrapped +--lines 50` reads one's log; `herdr --skill` prints the full reference for +inspecting and driving panes and agents. The plugins that provision these worktrees live in the +private `windmill-labs/windmill-herdr` — clone it and run `./setup.sh` to install them and the +keybindings they need. + +A worktree from the older **webmux** setup sets `$WEBMUX_WORKTREE_PATH` instead and is driven +through tmux: `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}')" -F +'#{pane_index} #{pane_current_command}'` for what is running, `tmux capture-pane` for a pane's log. + +See `backend/AGENTS.md` to restart the backend with different cargo features. The commands below are for a plain checkout with nothing running. - **Backend**: `cargo run` from `backend/` (API at http://localhost:8000) @@ -72,10 +83,16 @@ are for a plain checkout with nothing running. ### Per-worktree ports and database -In a webmux worktree the authoritative values live in -`$(git rev-parse --git-dir)/webmux/runtime.env` — `BACKEND_PORT`, `FRONTEND_PORT`, -`DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every pane sources it at startup. Read that -first: it is not a `.env*` file, so the repo's secret-file read rules don't stand in the way. +**`.env.local` in the worktree root holds the real values** — `BACKEND_PORT`, `FRONTEND_PORT`, +`REMOTE`, `DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every manager writes those fields, so it +is correct whichever one you are in. Read it with `cat .env.local` from a shell: the file-read +tool denies every `.env.*` path, and `DATABASE_URL` is written with an `export` prefix that a +`^DATABASE_URL=` grep misses. + +herdr keeps nothing besides that file; its hooks write it and read it back. A webmux worktree +additionally has `$(git rev-parse --git-dir)/webmux/runtime.env`, which every pane sources at +startup and which carries the extras `.env.local` lacks: `WEBMUX_*`, `WM_CLONE_DB`, +`USE_RUST_PLUGIN`. In a plain checkout, fall back to `.env` / `.env.local` (repo root) and `backend/.env`. @@ -84,19 +101,25 @@ hook. It is not a copy of the main dev instance: you get the `admins` workspace, `admin@windmill.dev` superadmin, the license key copied from the base database, and whatever the migrations seed — and none of your own workspaces, scripts, flows or apps. Create whatever a test needs. Cloning the base `windmill` database instead is -opt-in per project via `WM_CLONE_DB` in `.webmux.yaml`; read the note there before turning it on. +opt-in via `WM_CLONE_DB` (the `windmill.worktree` plugin's `config.env` under herdr, or +`.webmux.yaml` under webmux); read the note in `.webmux.yaml` before turning it on. The database is named after the **worktree directory, not the branch** (`scripts/worktree-common.sh`): `windmill_` + the directory basename with `-` → `_`, which Postgres then truncates at 63 -characters. Branch `hugo/win-2340-ai-agent-evals-standalone-agent-runs-and-eval-datasets` sits in -a worktree directory named `win-2340-…`, so its database is -`windmill_win_2340_ai_agent_evals_standalone_agent_runs_and_eval` — no `hugo_`, and the tail -chopped. Take `WM_DB_NAME` from `runtime.env` instead of reconstructing the name. Read those, or -discover from what is already running: +characters. herdr creates the directory under `~/.herdr/worktrees//` from the branch name +with each `/` turned into `-`, so branch +`hugo/win-2544-add-options-field-to-the-postgresql-resource-type` gets the database +`windmill_hugo_win_2544_add_options_field_to_the_postgresql_reso` — the whole branch name, cut +mid-word at the limit. The two drift apart as soon as the branch is renamed, and webmux named its +directories after the ticket alone, so reconstructing the name from the branch you are on is +wrong in both. Take `WM_DB_NAME` from `.env.local` instead. Read that, or discover from what is +already running: ```bash +# match on the worktree directory, truncated the way Postgres truncates it (63 - len('windmill_')): psql postgres://postgres:changeme@localhost:5432/postgres -tAc \ - "select datname from pg_database where datname like 'windmill%'" | grep "$(git branch --show-current | tr - _)" + "select datname from pg_database where datname like 'windmill%'" \ + | grep -F "$(basename "$(git rev-parse --show-toplevel)" | tr '/-' '_' | cut -c1-54)" # the port the frontend actually proxies to (REMOTE of this worktree's vite): for p in $(pgrep -f vite); do case "$(readlink /proc/$p/cwd)" in *"$(basename "$(git rev-parse --show-toplevel)")"*) tr '\0' '\n' < /proc/$p/environ | grep -E '^REMOTE=|^PORT=';; esac; done diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7bd84dfca3..750e0594e1 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -23,7 +23,7 @@ ## Cargo features & running the dev backend The dev backend runs under `cargo watch` and is launched by default with **only -`--features quickjs`** (see the tmux backend pane). That baseline compiles fast but +`--features quickjs`** (see the backend pane). That baseline compiles fast but **deliberately omits most functionality** — notably S3/object storage, the S3 proxy, all EE code, MCP, and every non-JS language runtime. A running server never gains a feature you didn't compile in: feature-gated routes 404 or return a `"requires "` stub. So if @@ -33,22 +33,30 @@ MUST **restart the backend with the appropriate features** for what you're worki ### Restarting the dev backend with the right features Restart in the **same pane**, so the relaunch inherits that pane's `DATABASE_URL`, `BACKEND_PORT` -and the rest of `runtime.env`. Scope every kill to this worktree — **never** +and the rest of the worktree environment. Scope every kill to this worktree — **never** `pkill -f target/debug/windmill`, which kills every sibling worktree's backend. -1. Find the backend pane by what it is running, not by index. The index depends on the webmux - profile: pane 1 is the backend under `full`, but the *frontend* under `frontendOnly`. +1. Find the backend pane by where it is running, not by index. Under herdr the two server panes + sit in the worktree's `backend/` and `frontend/`, so match on `cwd`: + + ```bash + herdr pane list --workspace "$HERDR_WORKSPACE_ID" + ``` + + Under webmux they are tmux panes, and the index depends on the profile: pane 1 is the backend + under `full`, but the *frontend* under `frontendOnly`. ```bash WIN=$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}') tmux list-panes -t "$WIN" -F '#{pane_index} #{pane_current_command} #{pane_pid}' ``` -2. Read the feature set it is **actually** running. `CARGO_FEATURES` in `runtime.env` is only what - the pane started with, and goes stale the first time anyone restarts by hand: +2. Read the feature set it is **actually** running. The `CARGO_FEATURES` the worktree was created + with is only what the pane started with, and goes stale the first time anyone restarts by hand: ```bash - ps --ppid -o args= + herdr pane process-info --pane # foreground_processes[].argv + ps --ppid -o args= # under webmux # /home/hugo/.cargo/bin/cargo-watch watch -x run --features quickjs ``` @@ -56,22 +64,25 @@ and the rest of `runtime.env`. Scope every kill to this worktree — **never** explicitly: ```bash - tmux send-keys -t "$WIN." C-c - tmux send-keys -t "$WIN." 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' Enter + herdr pane send-keys C-c + herdr pane run 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' + # under webmux: tmux send-keys -t "$WIN." C-c, then the same command line with Enter ``` Carry over every feature the old command had unless you mean to drop one — rebuilding the list from memory is how a backend silently loses `quickjs`. -4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the - worktree's `.env.local`, which `scripts/post-create.sh` writes and webmux reads. Do **not** - edit `runtime.env` for this — webmux regenerates it from metadata and `.env.local` every time - the worktree is opened, so an edit there is lost on the next reopen. Either way the change - only affects a future pane; step 3 is what takes effect now. +4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the worktree's + `.env.local`, which the worktree hooks write. A herdr pane sources that file directly, so the + next pane picks the value up. A webmux pane sources `webmux/runtime.env` instead, which webmux + regenerates from metadata and `.env.local` when the worktree is *opened* — so the value lands + on the next reopen, not the next pane, and editing `runtime.env` by hand is lost at that same + reopen. Either way the change only affects a future pane; step 3 is what takes effect now. -5. Re-capture the pane until `health check completed` appears before hitting the API. A cold - rebuild takes ~60s, and the previous run's success line is still in the scrollback, so a - capture taken too early reads as ready when it isn't. +5. Re-read the pane (`herdr pane read --source recent-unwrapped`, or `tmux + capture-pane`) until `health check completed` appears before hitting the API. A cold rebuild + takes ~60s, and the previous run's success line is still in the scrollback, so a capture taken + too early reads as ready when it isn't. cargo-watch only re-runs on a file change, so after an idle/failed run `touch README.md` (from `backend/`, where the watch runs) is a cheap retrigger (touching a `.rs` forces a full rebuild). 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 ea134a7ca8..6c85a75a4e 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. @@ -1533,6 +1535,7 @@ function serializeWorkspaceItemForRead(item: WorkspaceItem): unknown { summary: item.summary, value: summarizeAppValue(item.value as AppDraftValue), rawApp: item.rawApp, + executionMode: item.executionMode, isDraft: item.isDraft } } @@ -1829,13 +1832,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) } @@ -1845,7 +1863,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, @@ -2041,6 +2088,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 { @@ -2049,6 +2109,7 @@ async function readWorkspaceItem( summary: value.summary, value: metadata as unknown as AppDraftValue, rawApp: app.raw_app, + executionMode, isDraft: false } } @@ -3473,7 +3534,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, { @@ -7995,6 +8066,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 } diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 9489384405..bfae9bb5d2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -143,10 +143,11 @@ // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px on screens // ≥1760px (app.css), which grows the rem-based button content; a fixed-px rail would // not grow with it and the content would overflow. SIDEBAR_MIN_REM is the default - // expanded width (the old w-52); the handle only resizes when expanded and only - // widens from there — collapsing is the toggle button's job, not the drag's. + // expanded width (the old w-52); the handle only widens from there. Dragging the + // pointer into the snap zone at the screen's left edge collapses the rail. const SIDEBAR_MIN_REM = 13 const SIDEBAR_COLLAPSED_REM = 3 + const SIDEBAR_SNAP_COLLAPSE_REM = 6 // Root font-size in px, used to convert the pointer's clientX (px) into rem. function rootFontPx(): number { if (!BROWSER) return 16 @@ -169,11 +170,14 @@ // Width (in rem) the content offset must track: the icon strip when collapsed, // the user-chosen width otherwise. let railWidth = $derived(isCollapsed ? SIDEBAR_COLLAPSED_REM : sidebarWidth) - // Width transition shared by the rail and the content offset: none for the whole - // drag (the rail tracks the pointer 1:1 and hits the min as a hard wall, no - // friction), a plain ease only for the collapse/expand toggle. + // True for one transition's length after a drag crosses the snap zone's edge, so + // the collapse/expand eases before the rail goes back to tracking the pointer. + let sidebarSnapping = $state(false) + let sidebarSnapTimer: ReturnType | undefined + // Width transition shared by the rail and the content offset: none while a drag + // tracks the pointer 1:1, a plain ease for the toggle and the snap. let sidebarTransitionClass = $derived( - resizingSidebar ? '' : 'transition-all duration-200 ease-in-out' + resizingSidebar && !sidebarSnapping ? '' : 'transition-all duration-200 ease-in-out' ) // Set while a drag is live so it can be torn down if the layout unmounts // mid-drag (otherwise the window listeners would leak). @@ -192,12 +196,22 @@ handle.setPointerCapture(e.pointerId) } catch {} resizingSidebar = true + // Re-expanding a snap-collapsed rail restores the width it had before the drag. + const widthAtStart = sidebarWidth + const collapsedAtStart = isCollapsed // The rail is fixed at left:0, so the pointer's clientX is the width — in px. - // Convert to rem (the unit the rail is sized in) via the root font-size. Pure - // resize: clamp at the min so the rail stops there like a wall (dragging left - // never collapses — that's the toggle button's job). + // Convert to rem (the unit the rail is sized in) via the root font-size. The + // rail clamps at the min like a wall, until the pointer reaches the snap zone. const onMove = (ev: PointerEvent) => { - sidebarWidth = Math.max(SIDEBAR_MIN_REM, ev.clientX / rootFontPx()) + const x = ev.clientX / rootFontPx() + const collapse = x < SIDEBAR_SNAP_COLLAPSE_REM + if (collapse !== isCollapsed) { + isCollapsed = collapse + sidebarSnapping = true + clearTimeout(sidebarSnapTimer) + sidebarSnapTimer = setTimeout(() => (sidebarSnapping = false), 200) + } + sidebarWidth = collapse ? widthAtStart : Math.max(SIDEBAR_MIN_REM, x) } // pointercancel (and unmount, via onDestroy) must clear the state too, or // `resizingSidebar` sticks true — the overlay and handle highlight stay up @@ -205,7 +219,10 @@ const stop = () => { if (!resizingSidebar) return resizingSidebar = false + clearTimeout(sidebarSnapTimer) + sidebarSnapping = false widthPref.val = sidebarWidth + if (isCollapsed !== collapsedAtStart) collapsePref.val = isCollapsed window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', stop) window.removeEventListener('pointercancel', stop) @@ -1158,22 +1175,19 @@ class="flex-1 flex flex-col min-h-0 h-screen shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151] [html.github-dark_&]:shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))]" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG} > - {#if !isCollapsed} - - - {/if} + +