Files
windmill/cli/test/deploy_on_behalf_of_unit.test.ts
T
hugocasaandClaude Opus 5.5 0b980546b1 feat: agents run on behalf of their deployer (#11430)
* Revert "docs: agent editor comments describe runs as the caller"

This reverts commit d94919c9fe.

* Revert "refactor: agents run as the caller, with a note on what a shared one needs"

This reverts commit 0748a2fb51.

* 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>
2026-10-15 19:31:21 +02:00

133 lines
4.5 KiB
TypeScript

import { expect, test } from "bun:test";
import { deployItem } from "../windmill-utils-internal/src/deploy.ts";
// `deployItem` spreads the source item into the request body, and the principal it carries
// (`on_behalf_of`, at the top level for a script or flow and inside the policy for an app)
// names a username that only exists in the source workspace. Sending it to the target pairs
// one workspace's principal with the other's email, which the backend rejects. Deleting the
// spread is an easy regression, so pin that the principal never reaches the wire while the
// caller's chosen address does.
function recordingProvider(captured: [string, any][], flowExists: boolean) {
const source = {
on_behalf_of_email: "alice@corp",
on_behalf_of: "u/alice",
};
return {
existsFlowByPath: async () => flowExists,
existsScriptByPath: async () => true,
getFlowByPath: async () => ({
path: "f/x/f",
summary: "",
value: { modules: [] },
...source,
}),
createFlow: async (p: any) => void captured.push(["createFlow", p.requestBody]),
updateFlow: async (p: any) => void captured.push(["updateFlow", p.requestBody]),
getScriptByPath: async () => ({
path: "f/x/s",
summary: "",
content: "x",
language: "bun",
hash: "abc",
...source,
}),
createScript: async (p: any) =>
void captured.push(["createScript", p.requestBody]),
existsApp: async () => false,
getAppByPath: async () => ({
path: "f/x/a",
summary: "",
value: {},
raw_app: false,
policy: {
execution_mode: "publisher",
on_behalf_of: "u/alice",
on_behalf_of_email: "alice@corp",
},
}),
createApp: async (p: any) => void captured.push(["createApp", p.requestBody]),
} as any;
}
test("deployItem: never sends the source workspace's on_behalf_of", async () => {
const captured: [string, any][] = [];
// The clear is written out once per branch, so exercise all three: a flow that
// does not exist in the target (create), one that does (update — the branch
// `wmill workspace merge` takes for anything already deployed), and a script.
await deployItem(
recordingProvider(captured, false),
"flow" as any,
"f/x/f",
"src",
"dst",
"alice@corp",
);
await deployItem(
recordingProvider(captured, true),
"flow" as any,
"f/x/f",
"src",
"dst",
"alice@corp",
);
await deployItem(
recordingProvider(captured, false),
"script" as any,
"f/x/s",
"src",
"dst",
"alice@corp",
);
await deployItem(
recordingProvider(captured, false),
"app" as any,
"f/x/a",
"src",
"dst",
"alice@corp",
);
expect(captured.map(([fn]) => fn)).toEqual([
"createFlow",
"updateFlow",
"createScript",
"createApp",
]);
for (const [name, body] of captured) {
expect(body.preserve_on_behalf_of).toBe(true);
// Both surfaces spell it `on_behalf_of`; only its nesting differs — an app carries the
// identity inside its policy, the others at the top level.
const identity = name === "createApp" ? body.policy : body;
// The email is still overridden with the caller's choice...
expect(identity.on_behalf_of_email).toBe("alice@corp");
// ...while the principal is dropped, so the backend derives the target's own.
expect("on_behalf_of" in JSON.parse(JSON.stringify(identity))).toBe(false);
}
});
// An agent carries its principal inside its resource value, and is handed the chosen one as a
// principal. The source's must never be sent: without a choice the key is dropped, leaving the
// backend to make the agent the deployer's.
test("deployItem: an agent takes the chosen principal, never the source's", async () => {
const captured: any[] = [];
const provider = {
existsResource: async () => true,
getResource: async () => ({
path: "f/x/agent",
resource_type: "ai_agent",
description: "",
value: { system_prompt: "hi", on_behalf_of: "u/alice" },
}),
updateResource: async (p: any) => void captured.push(p.requestBody),
} as any;
await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", "u/bob");
await deployItem(provider, "resource" as any, "f/x/agent", "src", "dst", undefined);
expect(captured[0].value).toEqual({ system_prompt: "hi", on_behalf_of: "u/bob" });
expect(captured[0].preserve_on_behalf_of).toBe(true);
expect(captured[1].value).toEqual({ system_prompt: "hi" });
expect(captured[1].preserve_on_behalf_of).toBe(false);
});