Files
windmill/cli/src/commands/app/app.ts
T
fce635d3c4 feat: guest app execution mode, a role that takes no seat (#10929)
* feat: guest app execution mode, a fourth role that takes no seat

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: make the guest grant a server-minted label, not a declarable scope

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the guest session companion branch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: close the relabel hole, guest embed tokens, read-path switch, custom-path entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest tokens are not rescopable and guest embed tokens keep the sentinel

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest-derived tokens share one constraint set; gate sign-in on guest discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the label alone governs a guest; refuse guests with accounts; unserialize discovery

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest discovery fails closed; SAML aborts if the guest cookie write fails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* refactor: enforce the guest switch once at the auth door; sign-in for a guest of another app

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest app-mode decided once at the on-behalf resolver; clear a stale guest session before offering another app's sign-in

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest may use anonymous apps; await the stale-session logout; trim comments

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest's path confinement waits for the app's mode, so anonymous apps stay open to it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest target survives http (Lax cookie), rides SAML RelayState; tell account holders on arrival

* fix: a guest uses an anonymous app as itself; S3 uploads confined by app mode

* fix: a guest upload needs an app policy; a missing app does not skip the confinement

* fix: guests are gated on the Enterprise plan server-side; pin ee-repo-ref

* fix: the guest plan gate fails closed on non-enterprise builds; settings report the effective switch

* fix: guest controls read the plan, not the key; gate the guest tests on the features they need

* docs: tighten the guest session invariant comments

* feat: 100 free guests per 30 days, then a quarter seat each on Enterprise and a hard cap elsewhere; superadmin guest list; refusals reach the page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the cap is exact, an account ends a guest session at the door, popups close, and guest mode survives the CLI round trip

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* feat: a superadmin switch over guests for the whole instance; the pre-existing-user flag keeps its meaning

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: drop the dead guest-access helper, name the instance setting once, guests tab states, CE save order

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a guest app path is refused at the mint if it could widen the scope; the instance toggle waits for its reload

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guests stop at the launched-by-me job grant; canonical app paths at the mint and discovery; the toggle ends on the stored value

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: only the scope grammar's own characters bar an app path from guests, refused at deploy as well as at the mint

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: the deploy-time guest path guard checks the destination of a rename and refuses a leading slash

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: a workspace rename keeps the guest switch; the rename guard reads the deployed mode under the row lock

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* fix: guest_activity follows a workspace rename and goes with a workspace delete

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref to the state-bound guest target

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: pin ee-repo-ref; the guest cookie is never cleared by a callback

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* docs: the workspace-scoped guest_activity delete moves an instance-wide count; assert the mint records the guest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* test: the seeded allowance is a day old, so only the mint can write today's guest_activity row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BayTppRCstWX6qTf3LMco5

* chore: update ee-repo-ref to 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

This commit updates the EE repository reference after PR #769 was merged in windmill-ee-private.

Previous ee-repo-ref: 32841072aa396bff91d30bd91854fa348cb3c439

New ee-repo-ref: 1a10132e4f3cb442c7d0c2cf6e5d92d150bf6e07

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-09-04 22:47:28 +02:00

520 lines
16 KiB
TypeScript

import { requireLogin } from "../../core/auth.ts";
import { resolveWorkspace, validatePath } from "../../core/context.ts";
import { Command } from "@cliffy/command";
import { Table } from "@cliffy/table";
import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { sep as SEP, isAbsolute, resolve as pathResolve, relative as pathRelative, basename } from "node:path";
import { stat } from "node:fs/promises";
import * as windmillUtils from "@windmill-labs/shared-utils";
import { yamlParseFile } from "../../utils/yaml.ts";
import * as wmill from "../../../gen/services.gen.ts";
import { ListableApp, Policy } from "../../../gen/types.gen.ts";
import { GlobalOptions, isSuperset } from "../../types.ts";
import { getWmillYamlPath, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { readInlinePathSync } from "../../utils/utils.ts";
import devCommand from "./dev.ts";
import lintCommand from "./lint.ts";
import bundleCommand from "./bundle_command.ts";
import newCommand from "./new.ts";
import generateAgentsCommand from "./generate_agents.ts";
import { isVersionsGeq1585 } from "../sync/global.ts";
import type { PermissionedAsContext } from "../../core/permissioned_as.ts";
import { applyExtraPermsDiff } from "../../core/extra_perms.ts";
export interface AppFile {
guests?: boolean;
value: any;
public?: boolean;
summary: string;
policy: Policy;
// Mirrors granular ACLs on the app path. Omitted from app.yaml when no
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
// (see applyExtraPermsDiff) — never through update_app — so a perm-only
// change never bumps the app version.
extra_perms?: Record<string, boolean>;
}
const alreadySynced: string[] = [];
function respecializeFields(fields: Record<string, any>) {
Object.entries(fields).forEach(([k, v]) => {
if (typeof v == "object") {
if (v.value !== undefined) {
fields[k] = { value: v.value, type: "static" };
} else if (v.ctx !== undefined) {
fields[k] = { ctx: v.ctx, type: "ctx" };
} else if (v.expr !== undefined) {
fields[k] = {
expr: v.expr,
allowUserResources: v.allowUserResources,
type: "javascript",
};
}
}
});
}
export function repopulateFields(runnables: Record<string, any>) {
Object.values(runnables).forEach((v) => {
if (typeof v == "object") {
if (v.fields !== undefined) {
respecializeFields(v.fields);
}
}
});
}
export function replaceInlineScripts(
rec: any,
localPath: string,
addType: boolean
) {
if (!rec) {
return;
}
if (typeof rec == "object") {
return Object.entries(rec).flatMap(([k, v]) => {
if (k == "runType") {
if (addType) {
if (isVersionsGeq1585()) {
rec["type"] = "path";
} else {
rec["type"] = "runnableByPath";
}
}
} else if (k == "inlineScript" && typeof v == "object") {
if (addType) {
if (isVersionsGeq1585()) {
rec["type"] = "inline";
} else {
rec["type"] = "runnableByName";
}
}
const o: Record<string, any> = v as any;
if (o["content"] && o["content"].startsWith("!inline")) {
const basePath = localPath + o["content"].split(" ")[1];
o["content"] = readInlinePathSync(basePath);
}
if (o["lock"] && o["lock"].startsWith("!inline")) {
const basePath = localPath + o["lock"].split(" ")[1];
o["lock"] = readInlinePathSync(basePath);
}
} else {
replaceInlineScripts(v, localPath, addType);
}
});
}
return [];
}
export function isExecutionModeAnonymous(app: any) {
return app?.["policy"]?.["execution_mode"] == "anonymous";
}
export function isExecutionModeGuest(app: any) {
return app?.["policy"]?.["execution_mode"] == "guest";
}
export type AppExecutionMode = "anonymous" | "guest" | "publisher";
/** The access mode is the one policy field a tracked app keeps, as `public` (anonymous)
* or `guests` (guest); the rest of the policy is regenerated on push. */
export function markAccessFromPolicy(app: any) {
if (isExecutionModeAnonymous(app)) {
app.public = true;
} else if (isExecutionModeGuest(app)) {
app.guests = true;
}
}
export function executionModeFromAppFile(app: any): AppExecutionMode {
if (app?.["public"] ?? isExecutionModeAnonymous(app)) {
return "anonymous";
}
if (app?.["guests"] ?? isExecutionModeGuest(app)) {
return "guest";
}
return "publisher";
}
export async function pushApp(
workspace: string,
remotePath: string,
localPath: string,
message?: string,
permissionedAsContext?: PermissionedAsContext
): Promise<void> {
if (alreadySynced.includes(localPath)) {
return;
}
alreadySynced.push(localPath);
remotePath = remotePath.replaceAll(SEP, "/");
let app: any = undefined;
// deleting old app if it exists in raw mode
try {
app = await wmill.getAppByPath({
workspace,
path: remotePath,
});
} catch {
//ignore
}
let remoteOnBehalfOf: string | undefined;
let remoteOnBehalfOfEmail: string | undefined;
if (app?.policy) {
remoteOnBehalfOf = app.policy.on_behalf_of;
remoteOnBehalfOfEmail = app.policy.on_behalf_of_email;
}
markAccessFromPolicy(app);
// console.log(app);
if (app) {
app.policy = undefined;
}
if (!localPath.endsWith(SEP)) {
localPath += SEP;
}
const path = localPath + "app.yaml";
const localApp = (await yamlParseFile(path)) as AppFile;
replaceInlineScripts(localApp.value, localPath, true);
await generatingPolicy(localApp, remotePath, executionModeFromAppFile(localApp));
const preserveFields: { preserve_on_behalf_of?: boolean } = {};
if (permissionedAsContext?.userIsAdminOrDeployer) {
if (app) {
if (localApp.policy && remoteOnBehalfOf) {
(localApp.policy as any).on_behalf_of = remoteOnBehalfOf;
(localApp.policy as any).on_behalf_of_email = remoteOnBehalfOfEmail;
preserveFields.preserve_on_behalf_of = true;
log.info(`Preserving ${remoteOnBehalfOfEmail ?? remoteOnBehalfOf} as permissioned_as for app ${remotePath}`);
}
}
// On create: backend applies folder defaults
}
// extra_perms goes through /acls/* — strip from the body so a perms-only
// edit never bumps the app version (see applyExtraPermsDiff for details).
const { extra_perms: localPerms, ...localAppBody } = localApp as AppFile & {
extra_perms?: Record<string, boolean>;
};
if (app) {
if (isSuperset(localAppBody, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
} else {
log.info(colors.bold.yellow(`Updating app ${remotePath}...`));
await wmill.updateApp({
workspace,
path: remotePath,
requestBody: {
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
} else {
log.info(colors.yellow.bold("Creating new app..."));
await wmill.createApp({
workspace,
requestBody: {
path: remotePath,
deployment_message: message,
...localAppBody,
...preserveFields,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
}
// Independent perms sync via /acls/* — self-contained log + non-fatal errors.
// No refetch: extra_perms is item-specific and folder perms are never merged
// onto item.extra_perms, and the body sent to update_app / create_app omits
// the field — so the value we already have from getAppByPath is authoritative.
await applyExtraPermsDiff(
workspace,
"app",
remotePath,
localPerms,
(app as any)?.extra_perms,
);
}
export async function generatingPolicy(
app: any,
path: string,
executionMode: AppExecutionMode
) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, undefined);
app.policy.execution_mode = executionMode;
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
}
}
async function list(opts: GlobalOptions & { includeDraftOnly?: boolean; json?: boolean }) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
let page = 0;
const perPage = 10;
const total: ListableApp[] = [];
while (true) {
const res = await wmill.listApps({
workspace: workspace.workspaceId,
page,
perPage,
includeDraftOnly: opts.includeDraftOnly ?? false,
});
page += 1;
total.push(...res);
if (res.length < perPage) {
break;
}
}
if (opts.json) {
console.log(JSON.stringify(total));
} else {
new Table()
.header(["path", "summary"])
.padding(2)
.border(true)
.body(total.map((x) => [x.path, x.summary]))
.render();
}
}
async function get(opts: GlobalOptions & { json?: boolean }, path: string) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const a = await wmill.getAppByPath({
workspace: workspace.workspaceId,
path,
});
if (opts.json) {
console.log(JSON.stringify(a));
} else {
console.log(colors.bold("Path:") + " " + a.path);
console.log(colors.bold("Summary:") + " " + (a.summary ?? ""));
console.log(colors.bold("Created by:") + " " + (a.created_by ?? ""));
}
}
const APP_FOLDER_SUFFIXES = ["__raw_app", ".raw_app", "__app", ".app"] as const;
async function push(
opts: GlobalOptions,
filePath?: string,
remotePath?: string
) {
// Capture original CWD before resolveWorkspace, which may chdir to the
// wmill.yaml root. We need it to resolve relative inputs and to derive
// the remote path from the user's location when auto-inferring.
const originalCwd = process.cwd();
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
// Auto-infer file path from CWD when omitted
if (!filePath) {
filePath = originalCwd;
}
const absoluteFilePath = isAbsolute(filePath)
? filePath
: pathResolve(originalCwd, filePath);
// Detect app folder type (regular vs raw)
const normalizedPath = absoluteFilePath.endsWith(SEP)
? absoluteFilePath.slice(0, -1)
: absoluteFilePath;
const dirName = basename(normalizedPath);
const isRawAppByName =
dirName.endsWith("__raw_app") || dirName.endsWith(".raw_app");
const isAppByName = dirName.endsWith("__app") || dirName.endsWith(".app");
let hasRawAppYaml = false;
let hasAppYaml = false;
try {
await stat(normalizedPath + SEP + "raw_app.yaml");
hasRawAppYaml = true;
} catch { /* not a raw app */ }
if (!hasRawAppYaml) {
try {
await stat(normalizedPath + SEP + "app.yaml");
hasAppYaml = true;
} catch { /* not an app */ }
}
if (!isRawAppByName && !isAppByName && !hasRawAppYaml && !hasAppYaml) {
log.error(
colors.red(
`'${filePath}' is not an app folder (no app.yaml or raw_app.yaml, and not a *.app/*.raw_app folder).`
)
);
return;
}
// Auto-infer remote path from the folder location relative to wmill.yaml root
if (!remotePath) {
const wmillYamlPath = getWmillYamlPath();
if (!wmillYamlPath) {
log.error(
colors.red(
"Could not infer remote path: no wmill.yaml found. Run 'wmill init' or pass <remote_path> explicitly."
)
);
return;
}
// After resolveWorkspace, process.cwd() is the wmill.yaml dir
const wmillRoot = process.cwd();
let inferred = pathRelative(wmillRoot, normalizedPath).replaceAll(SEP, "/");
if (inferred.startsWith("..") || isAbsolute(inferred)) {
log.error(
colors.red(
`Could not infer remote path: '${filePath}' is outside the wmill.yaml root (${wmillRoot}). Move the folder under the root or pass <remote_path> explicitly.`
)
);
return;
}
for (const suffix of APP_FOLDER_SUFFIXES) {
if (inferred.endsWith(suffix)) {
inferred = inferred.slice(0, -suffix.length);
break;
}
}
if (!inferred) {
log.error(
colors.red(
"Could not infer remote path: app folder is at the wmill.yaml root. Pass <remote_path> explicitly."
)
);
return;
}
if (
!inferred.startsWith("u/") &&
!inferred.startsWith("g/") &&
!inferred.startsWith("f/")
) {
log.error(
colors.red(
`Could not infer remote path: '${inferred}' is not under u/, g/, or f/. Move the app under one of these prefixes or pass <remote_path> explicitly.`
)
);
return;
}
remotePath = inferred;
log.info(colors.gray(`Inferred remote path: ${remotePath}`));
}
if (!validatePath(remotePath)) {
return;
}
if (isRawAppByName || hasRawAppYaml) {
const { pushRawApp } = await import("./raw_apps.ts");
const merged = await mergeConfigWithConfigFile(opts);
await pushRawApp(
workspace.workspaceId,
remotePath,
absoluteFilePath,
undefined,
merged.defaultTs,
);
log.info(colors.bold.underline.green("Raw app pushed"));
} else {
await pushApp(workspace.workspaceId, remotePath, absoluteFilePath);
log.info(colors.bold.underline.green("App pushed"));
}
}
const command = new Command()
.description("app related commands")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("list", "list all apps")
.option("--json", "Output as JSON (for piping to jq)")
.action(list as any)
.command("get", "get an app's details")
.arguments("<path:string>")
.option("--json", "Output as JSON (for piping to jq)")
.action(get as any)
.command(
"push",
"push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml."
)
.arguments("[file_path:string] [remote_path:string]")
.action(push as any)
.command("dev", devCommand)
.command("lint", lintCommand)
.command("bundle", bundleCommand)
.command("new", newCommand)
.command("generate-agents", generateAgentsCommand)
.command(
"generate-locks",
'DEPRECATED: re-generate app lockfiles. Use "wmill generate-metadata" instead.'
)
// Deprecated compatibility command. Keep it working for older repos, but
// exclude it from generated system prompt docs.
// @deprecated use `wmill generate-metadata`
.arguments("[app_folder:string]")
.option("--yes", "Skip confirmation prompt")
.option("--dry-run", "Perform a dry run without making changes")
.option(
"--default-ts <runtime:string>",
"Default TypeScript runtime (bun or deno)"
)
.action(async (opts: any, appFolder: string | undefined) => {
log.warn(
colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.')
);
const { generateLocksCommand } = await import("./app_metadata.ts");
await generateLocksCommand(opts, appFolder);
})
.command(
"set-permissioned-as",
"Set the on_behalf_of_email for an app (requires admin or wm_deployers group)"
)
.arguments("<path:string> <email:string>")
.action((async (opts: any, appPath: string, email: string) => {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const { lookupUsernameByEmail } = await import("../../core/permissioned_as.ts");
const cache = new Map<string, { username: string; email: string }>();
const username = await lookupUsernameByEmail(workspace.workspaceId, email, cache);
const remote = await wmill.getAppByPath({
workspace: workspace.workspaceId,
path: appPath,
});
if (!remote) throw new Error(`App ${appPath} not found`);
// Only spread remote.policy — spreading the full remote object would include
// `value` and trigger a new app version on every call. EditApp has all-Option
// fields, so a minimal body only updates the policy column.
await wmill.updateApp({
workspace: workspace.workspaceId,
path: appPath,
requestBody: {
policy: {
...(remote.policy as any),
on_behalf_of: `u/${username}`,
on_behalf_of_email: email,
} as any,
preserve_on_behalf_of: true,
// Preserve any user draft at this path (see backend skip_draft_deletion).
skip_draft_deletion: true,
},
});
log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`));
}) as any);
export default command;