mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-20 08:03:28 +00:00
* Revert "docs: agent editor comments describe runs as the caller" This reverts commitd94919c9fe. * Revert "refactor: agents run as the caller, with a note on what a shared one needs" This reverts commit0748a2fb51. * fix: a run as an identity continues only the caller's own conversation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an agent run as its identity takes only attachments its caller may read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the resource editor keeps an agent's identity on an admin's save Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: regenerate workspace tool schemas for preserve_on_behalf_of Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the rust client sets preserve_on_behalf_of on resource writes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: agents run on behalf of their identity only when they opt in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: an agent holds an identity only while it runs on behalf of it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: reduce the agent identity migration to clearing untrusted keys Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse decoded variable references in inputs to an agent run as its identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: user renames and offboarding re-point an agent's identity Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: refuse model-written tool arguments that the tool's job would interpolate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: agent on-behalf-of migration after main's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: the agent identity migration also clears trashed agents Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
6.3 KiB
TypeScript
161 lines
6.3 KiB
TypeScript
/**
|
|
* The pre-check is what stops a push from silently reassigning an item's run-as
|
|
* user. Raw apps were missing from it, so the one kind whose file records no
|
|
* policy at all was also the one that changed owner without a word.
|
|
*/
|
|
|
|
import { expect, test } from "bun:test";
|
|
import { preCheckPermissionedAs } from "../src/core/permissioned_as.ts";
|
|
|
|
/** Non-interactive and without the override flag, the pre-check exits rather
|
|
* than reassigning silently — so a thrown exit is the signal it fired. */
|
|
type Shape = "edited" | "added" | "deleted";
|
|
|
|
function change(path: string, name: Shape = "edited") {
|
|
return { name, path, before: "summary: x\n", content: "summary: x\n" };
|
|
}
|
|
|
|
async function precheck(
|
|
changes: ReturnType<typeof change>[],
|
|
): Promise<string | undefined> {
|
|
const exit = process.exit;
|
|
let code: number | undefined;
|
|
(process as any).exit = (c?: number) => {
|
|
code = c;
|
|
throw new Error(`exit:${c}`);
|
|
};
|
|
const logged: string[] = [];
|
|
const err = console.error;
|
|
console.error = (...a: unknown[]) => void logged.push(a.join(" "));
|
|
try {
|
|
await preCheckPermissionedAs(changes, "pusher@corp", false, false, false);
|
|
} catch (e) {
|
|
if (!String(e).startsWith("Error: exit:")) throw e;
|
|
} finally {
|
|
(process as any).exit = exit;
|
|
console.error = err;
|
|
}
|
|
return code === undefined ? undefined : logged.join("\n");
|
|
}
|
|
|
|
test("a raw-app push warns the non-deployer it will take over the run-as user", async () => {
|
|
const message = await precheck([change("f/test/myapp.raw_app/index.tsx")]);
|
|
|
|
expect(message).toBeDefined();
|
|
expect(message).toContain("f/test/myapp.raw_app");
|
|
expect(message).toContain("pusher@corp");
|
|
});
|
|
|
|
// Deleting one file re-pushes the whole app rather than deleting it, so the
|
|
// takeover happens there too.
|
|
test("deleting one of an app's files warns like editing one", async () => {
|
|
const message = await precheck([
|
|
change("f/test/myapp.raw_app/gone.tsx", "deleted"),
|
|
]);
|
|
|
|
expect(message).toContain("f/test/myapp.raw_app");
|
|
});
|
|
|
|
// The metadata file going with it means the app itself is created or removed —
|
|
// neither takes an owner over.
|
|
test("an app arriving or leaving whole is not a takeover", async () => {
|
|
const created = await precheck([
|
|
change("f/test/new.raw_app/raw_app.yaml", "added"),
|
|
change("f/test/new.raw_app/index.tsx", "added"),
|
|
]);
|
|
const removed = await precheck([
|
|
change("f/test/old.raw_app/raw_app.yaml", "deleted"),
|
|
change("f/test/old.raw_app/index.tsx", "deleted"),
|
|
]);
|
|
|
|
expect(created).toBeUndefined();
|
|
expect(removed).toBeUndefined();
|
|
});
|
|
|
|
// An app carries no owner in its files, so nothing about it depends on their
|
|
// content — an empty one redeploys it exactly like any other.
|
|
test("an empty file still counts as a change to the app", async () => {
|
|
const added = await precheck([
|
|
{ name: "added", path: "f/test/myapp.raw_app/blank.ts", content: "" },
|
|
]);
|
|
const edited = await precheck([
|
|
{ name: "edited", path: "f/test/myapp.raw_app/blank.ts", before: "" },
|
|
]);
|
|
|
|
expect(added).toContain("f/test/myapp.raw_app");
|
|
expect(edited).toContain("f/test/myapp.raw_app");
|
|
});
|
|
|
|
// `extractFolderPath` normalizes separators but the metadata predicates match a
|
|
// literal `/`, so a Windows path must not take a different branch.
|
|
test("a Windows path classifies the same as its posix twin", async () => {
|
|
const created = await precheck([
|
|
change("f\\test\\new.raw_app\\raw_app.yaml", "added"),
|
|
change("f\\test\\new.raw_app\\index.tsx", "added"),
|
|
]);
|
|
const edited = await precheck([
|
|
change("f\\test\\myapp.raw_app\\index.tsx"),
|
|
]);
|
|
|
|
expect(created).toBeUndefined();
|
|
expect(edited).toContain("f/test/myapp.raw_app");
|
|
});
|
|
|
|
// `collectAppFiles` never sends these, and the sync diff never stops listing
|
|
// them (nothing uploads them, so they stay "added" forever) — so warning on one
|
|
// would gate every push of a scaffolded app on the override flag.
|
|
test("a file the push never sends is not a change to the app", async () => {
|
|
const artifacts = await precheck([
|
|
change("f/test/myapp.raw_app/AGENTS.md", "added"),
|
|
change("f/test/myapp.raw_app/sql_to_apply/a.sql", "added"),
|
|
change("f/test/myapp.raw_app/node_modules/dep/index.js", "added"),
|
|
change("f/test/myapp.raw_app/recordings/r.json", "added"),
|
|
change("f/test/myapp.raw_app/package-lock.json"),
|
|
change("f/test/myapp.raw_app/wmill.d.ts"),
|
|
// Only the backend folder's *top level* is a runnable; nothing reads deeper,
|
|
// so the depth limit is what keeps a `backend/node_modules/` from becoming
|
|
// the perpetual diff this predicate exists to remove.
|
|
change("f/test/myapp.raw_app/backend/node_modules/dep/index.js", "added"),
|
|
]);
|
|
// The three channels a push does send through: bundled file, metadata, runnable.
|
|
const sent = await precheck([change("f/test/myapp.raw_app/index.tsx")]);
|
|
const meta = await precheck([change("f/test/myapp.raw_app/raw_app.yaml")]);
|
|
const runnable = await precheck([change("f/test/myapp.raw_app/backend/a.ts")]);
|
|
// The runnable channel is not the bundle: the bundle's name exclusions don't
|
|
// reach into it, so a runnable file sharing one of those names still deploys.
|
|
const namesake = await precheck([
|
|
change("f/test/myapp.raw_app/backend/wmill.d.ts"),
|
|
]);
|
|
|
|
expect(artifacts).toBeUndefined();
|
|
expect(sent).toContain("f/test/myapp.raw_app");
|
|
expect(meta).toContain("f/test/myapp.raw_app");
|
|
expect(runnable).toContain("f/test/myapp.raw_app");
|
|
expect(namesake).toContain("f/test/myapp.raw_app");
|
|
});
|
|
|
|
test("an app is listed once however many of its files changed", async () => {
|
|
const message = await precheck([
|
|
change("f/test/myapp.raw_app/index.tsx"),
|
|
change("f/test/myapp.raw_app/raw_app.yaml"),
|
|
change("f/test/myapp.raw_app/backend/a.ts"),
|
|
change("f/test/low.app/app.yaml"),
|
|
change("f/test/low.app/inline.ts"),
|
|
]);
|
|
|
|
expect(message).toContain("2 item(s)");
|
|
});
|
|
|
|
// An agent has an identity only while `run_on_behalf_of` is on, so only then
|
|
// can a push take it over.
|
|
test("an agent warns only while it runs on behalf of someone", async () => {
|
|
const agent = (run: boolean) => ({
|
|
name: "edited" as const,
|
|
path: "f/test/bot.resource.yaml",
|
|
before: `resource_type: ai_agent\nvalue:\n system_prompt: hi\n run_on_behalf_of: ${run}\n`,
|
|
});
|
|
|
|
expect(await precheck([agent(true)])).toContain("f/test/bot.resource.yaml");
|
|
expect(await precheck([agent(false)])).toBeUndefined();
|
|
});
|