Files
windmill/frontend/src/lib/utils_draft_deploy.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

265 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import { draftBaseIsStale, deployDraft } from './utils_draft_deploy'
vi.mock('$lib/gen', () => ({
ScriptService: { getScriptByPath: vi.fn(), createScript: vi.fn() },
FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() },
DraftService: { deleteDraft: vi.fn() },
AppService: {},
VariableService: {},
ResourceService: { getResource: vi.fn(), updateResource: vi.fn(), createResource: vi.fn() },
ScheduleService: {},
HttpTriggerService: {},
WebsocketTriggerService: {},
PostgresTriggerService: {},
KafkaTriggerService: {},
NatsTriggerService: {},
MqttTriggerService: {},
AmqpTriggerService: {},
SqsTriggerService: {},
GcpTriggerService: {},
AzureTriggerService: {},
EmailTriggerService: {}
}))
vi.mock('$lib/userDraftDbSyncer.svelte', () => ({
UserDraftDbSyncer: { save: vi.fn(), recordRemoteSync: vi.fn() }
}))
vi.mock('$lib/workspaceDrafts.svelte', () => ({ invalidateWorkspaceDrafts: vi.fn() }))
vi.mock('$lib/workspaceComparison', () => ({ invalidateWorkspaceComparison: vi.fn() }))
vi.mock('$lib/localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
vi.mock('$lib/rawAppDeploy', () => ({ deployRawAppDraft: vi.fn() }))
vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi.fn() }))
vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() }))
vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] }))
import { ScriptService, FlowService, ResourceService } from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
// draftBaseIsStale compares a draft's base pointer against the deployed head
// of the item it was fetched with (`get_draft=true`). Shared by CompareDrafts
// and the session Edits drawer — a regression here silently hides (or
// fabricates) the "started from an older deployed version" warning.
describe('draftBaseIsStale', () => {
it('script: stale iff draft_base differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v1' })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v2' })).toBe(false)
})
it('script: no base or no head → not stale (nothing to compare)', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: {} })).toBe(false)
expect(draftBaseIsStale('script', { draft_base: 'v1' })).toBe(false)
})
it('flow: compares draft_base against the deployed version_id as text', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '5' })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '7' })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7 })).toBe(false)
})
it('app/raw_app: compares draft_base against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft_base: '2' })).toBe(true)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft_base: '3' })).toBe(false)
expect(draftBaseIsStale('app', { versions: [], draft_base: '2' })).toBe(false)
})
it('no draft on the response → not stale', () => {
expect(draftBaseIsStale('script', { hash: 'v2' })).toBe(false)
expect(draftBaseIsStale('script', undefined)).toBe(false)
})
})
// Without preserve_on_behalf_of the backend rewrites on_behalf_of_email to the
// deploying user, so deploying a draft silently re-points the runnable's
// identity.
describe('deployDraft preserves on_behalf_of', () => {
beforeEach(() => vi.clearAllMocks())
it('script: forwards the flag when the draft carries an on_behalf_of_email', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
draft: { path: 'f/admin/send_email', on_behalf_of_email: 'alice@windmill.dev' }
} as any)
expect(await deployDraft('script', 'f/admin/send_email', 'ws')).toEqual({ success: true })
expect(ScriptService.createScript).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
on_behalf_of_email: 'alice@windmill.dev',
preserve_on_behalf_of: true
})
})
)
})
it('script: omits the flag when the draft has no on_behalf_of_email', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
draft: { path: 'f/admin/send_email' }
} as any)
await deployDraft('script', 'f/admin/send_email', 'ws')
expect(ScriptService.createScript).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ preserve_on_behalf_of: undefined })
})
)
})
it('flow: forwards the flag when the draft carries an on_behalf_of_email', async () => {
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
draft: { path: 'f/admin/notify', value: {}, on_behalf_of_email: 'alice@windmill.dev' }
} as any)
expect(await deployDraft('flow', 'f/admin/notify', 'ws')).toEqual({ success: true })
expect(FlowService.updateFlow).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
on_behalf_of_email: 'alice@windmill.dev',
preserve_on_behalf_of: true
})
})
)
})
it('agent: claims the deployed identity, which its draft never carries', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed', on_behalf_of: 'u/alice' },
draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } }
} as any)
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true })
expect(ResourceService.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
value: { system_prompt: 'drafted', on_behalf_of: 'u/alice' },
preserve_on_behalf_of: true
})
})
)
})
})
// The backend skips the dependency job when `lock` is set, so a draft that inherited the
// deployed lock would deploy new content against the old lockfile.
describe('deployDraft: script lock', () => {
beforeEach(() => vi.clearAllMocks())
it('never sends the draft lock, so the backend relocks', async () => {
vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({
hash: 'v1',
draft: {
path: 'f/admin/fetch',
content: 'import requests',
lock: 'certifi==2024.1.1',
lock_error_logs: 'old error'
}
} as any)
await deployDraft('script', 'f/admin/fetch', 'ws')
const body = vi.mocked(ScriptService.createScript).mock.calls[0][0].requestBody
expect(body).not.toHaveProperty('lock')
expect(body).not.toHaveProperty('lock_error_logs')
})
it('flow: drops the lock of a step whose code differs from the deployed one only', async () => {
const step = (id: string, content: string) => ({
id,
value: { type: 'rawscript', language: 'python3', content, lock: 'requests==2.0' }
})
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
value: { modules: [step('edited', 'import requests'), step('kept', 'import requests')] },
draft: {
path: 'f/admin/flow',
value: { modules: [step('edited', 'import yaml'), step('kept', 'import requests')] }
}
} as any)
await deployDraft('flow', 'f/admin/flow', 'ws')
const [edited, kept] = vi.mocked(FlowService.updateFlow).mock.calls[0][0].requestBody.value
.modules as any[]
expect(edited.value).not.toHaveProperty('lock')
expect(kept.value.lock).toBe('requests==2.0')
})
// A never-deployed flow is often a copy or an import, whose locks match its code.
it('flow: keeps every lock when nothing is deployed to compare against', async () => {
const step = { id: 'copied', value: { type: 'rawscript', content: 'x', lock: 'requests==2.0' } }
vi.mocked(FlowService.getFlowByPath).mockResolvedValueOnce({
no_deployed: true,
value: { modules: [step] },
draft: { path: 'f/admin/copy', value: { modules: [step] } }
} as any)
await deployDraft('flow', 'f/admin/copy', 'ws', { draftOnly: true })
const [copied] = vi.mocked(FlowService.createFlow).mock.calls[0][0].requestBody.value
.modules as any[]
expect(copied.value.lock).toBe('requests==2.0')
})
})
// The resource branch reads the item again when the deploy lands, and falls back to the deployed
// row when the draft has gone. That row keeps its value under `value` and carries no `args` at
// all, so reading it as a draft (`value: d.args ?? {}`) would replace a live resource with `{}`.
describe('deployDraft: resource with no draft', () => {
beforeEach(() => vi.clearAllMocks())
it('writes nothing rather than `{}` over the deployed value', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' }
} as any)
// `noop` is what lets a caller deploying one specific draft tell "nothing to promote" apart
// from "deployed", instead of reporting an agent as deployed that was never written.
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({
success: true,
noop: true
})
expect(ResourceService.updateResource).not.toHaveBeenCalled()
expect(ResourceService.createResource).not.toHaveBeenCalled()
// Nor does it touch the draft row. There was none of this user's to delete, so the only row
// the cleanup could reach is one written after the read: an edit destroyed without ever
// having been deployed. Clearing the baseline would be the same bug by another route, since
// a delete with no baseline is the unconditional one.
expect(UserDraftDbSyncer.save).not.toHaveBeenCalled()
expect(UserDraftDbSyncer.recordRemoteSync).not.toHaveBeenCalled()
})
it('still deploys normally when the draft is there, and keys the cleanup to the row it read', async () => {
vi.mocked(ResourceService.getResource).mockResolvedValueOnce({
path: 'f/support/triage_agent',
resource_type: 'ai_agent',
value: { system_prompt: 'deployed' },
draft_saved_at: '2026-01-01T00:00:00Z',
draft: { path: 'f/support/triage_agent', args: { system_prompt: 'drafted' } }
} as any)
expect(await deployDraft('resource', 'f/support/triage_agent', 'ws')).toEqual({ success: true })
expect(ResourceService.updateResource).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ value: { system_prompt: 'drafted' } })
})
)
// The draft delete that follows is conditional on this baseline. With no baseline the backend
// deletes unconditionally, destroying a draft saved between the read and the delete without
// ever having deployed it, so the timestamp has to be the one from the row just promoted.
expect(UserDraftDbSyncer.recordRemoteSync).toHaveBeenCalledWith(
{ workspace: 'ws', itemKind: 'resource', path: 'f/support/triage_agent' },
'2026-01-01T00:00:00Z'
)
expect(UserDraftDbSyncer.save).toHaveBeenCalledWith(
expect.objectContaining({ path: 'f/support/triage_agent', value: null, immediate: true })
)
// Order is the whole point: a delete issued before the seed carries whatever baseline the tab
// happened to hold, which for a caller that only read through a listing is none at all.
expect(vi.mocked(UserDraftDbSyncer.recordRemoteSync).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(UserDraftDbSyncer.save).mock.invocationCallOrder[0]
)
})
})