mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into glm/install-workspace-picker
This commit is contained in:
@@ -1,15 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse allowance for scratch file ops: auto-allow a single, plain, single-line
|
||||
# `mkdir` / `cp` / `mv` / `touch` / `chmod` / `tar` / `unzip` whose every path operand
|
||||
# resolves under /tmp. Anything else makes no decision (exit 0) and falls back to the normal
|
||||
# permission flow — where `Bash(mv:*)` and `Bash(chmod:*)` in the `ask` list prompt. A
|
||||
# PreToolUse `allow` overrides those ask rules, which is why this is a hook and not an allow
|
||||
# rule: permission rules match a command prefix, so they can only constrain the FIRST operand.
|
||||
# `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix, and requiring every operand is the point.
|
||||
# PreToolUse allowance for scratch file ops: auto-allow `mkdir` / `cp` / `mv` / `touch` /
|
||||
# `chmod` whose every path operand resolves inside one of the roots `path_class` recognizes —
|
||||
# under /tmp, or inside a git working tree under $HOME — and `tar` / `unzip` confined to /tmp.
|
||||
# Anything else makes no decision (exit 0) and falls back to the normal permission flow, except
|
||||
# for `mv` and `chmod`: those get an explicit `ask`, the only prompt they get (see
|
||||
# lib-guarded-verb.sh).
|
||||
#
|
||||
# Requiring the sources under /tmp too (not just the destination) keeps this from becoming a
|
||||
# read-exfiltration path around the `Read(**/.env)` / `Read(**/secrets/**)` deny rules: a copy
|
||||
# out of the project into /tmp would land the content somewhere `Read(/tmp/**)` allows.
|
||||
# The command is read one segment at a time, so chaining and line breaks carry no weight of
|
||||
# their own: `cd /tmp/scratch && mv /tmp/a /tmp/b` is proved on the operands of the `mv`. A
|
||||
# decision covers the whole command line, so `allow` is emitted only when every segment is one
|
||||
# of these verbs proved here or a `cd` that resolved, AND exactly one of them writes (see the
|
||||
# gate at the foot of this file — an earlier write can change what a later operand means). A
|
||||
# line that mixes a proven op with some other command makes no decision instead and leaves that
|
||||
# line to the normal permission flow, rather than waving an unexamined command through with it.
|
||||
#
|
||||
# This is a hook rather than an allow rule because permission rules match a command prefix, so
|
||||
# they can only constrain the FIRST operand. `cp /tmp/x ~/.zshrc` matches a `cp /tmp/` prefix,
|
||||
# and requiring every operand is the point.
|
||||
#
|
||||
# One operation may not straddle two roots, sources included, and a sibling checkout is a
|
||||
# different root — `path_class` names the git tree, not just its kind. A copy out of a checkout
|
||||
# into /tmp would be a read-exfiltration path around the `Read(**/secrets/**)` / `Read(**/*.pem)`
|
||||
# deny rules, since the content lands where `Read(/tmp/**)` allows it to be read back, and one
|
||||
# out of a repo the Read tool is not confined to would do the same for that repo. Keeping every
|
||||
# operand of one operation inside a single root closes both without restating those rules here.
|
||||
# The checkout root itself is what makes an in-repo `mv` or `chmod` auto-allowable: deleting a
|
||||
# file there has never prompted, and moving or chmod-ing one is not the graver act.
|
||||
#
|
||||
# Deny-by-default tokenizing, in the same spirit as guard-rm-outside-tmp.sh: every path token
|
||||
# must consist only of alphanumerics and `. _ / -`. That set contains none of the characters
|
||||
@@ -17,12 +33,19 @@
|
||||
# any glob character, so all of those forms fail by construction. `realpath -m` then resolves
|
||||
# `..` and existing symlinks, so `/tmp/link` pointing at /etc/passwd is caught.
|
||||
#
|
||||
# `tar` and `unzip` keep the stricter rule — /tmp only, and absolute operands only — because
|
||||
# their positional grammar makes a bare word ambiguous: `tar P -xf ...` is --absolute-names,
|
||||
# not a file named P, and resolving it as a path would put an option in a root and allow it.
|
||||
# The other five take relative operands, resolved against the working directory that `cd`
|
||||
# tracking maintains, since for those a bare word really is a path (a GNU option starts with
|
||||
# `-`, and the option allowlist below rejects the ones that would change symlink handling).
|
||||
#
|
||||
# `tar` and `unzip` get their own parser: their write destination arrives as a flag VALUE
|
||||
# (`-C`, `-d`) rather than a positional, and a bundle like `-xzf` consumes the token after it.
|
||||
# Flags are an allowlist, not a denylist, so `-P` / `--absolute-names` — which turn off tar's
|
||||
# refusal to extract `..` and absolute member paths — defer rather than needing enumeration.
|
||||
# Extraction additionally requires an explicit destination under /tmp, or a cwd already under
|
||||
# /tmp, since otherwise members land in the project checkout.
|
||||
# Extraction additionally requires an explicit destination under /tmp, or a working directory
|
||||
# already under /tmp, since otherwise members land in the project checkout.
|
||||
#
|
||||
# Residual risk accepted: an archive whose members include a symlink pointing out of /tmp
|
||||
# followed by a write through it can still escape, because tar applies member symlinks as it
|
||||
@@ -31,6 +54,7 @@
|
||||
#
|
||||
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
|
||||
set -uo pipefail
|
||||
. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
|
||||
|
||||
input=$(cat)
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
@@ -38,25 +62,62 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
[ -z "$cmd" ] && exit 0
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
|
||||
|
||||
# A newline separates commands, and the tokenizer below only reads the first line — defer.
|
||||
case "$cmd" in *$'\n'*) exit 0 ;; esac
|
||||
# Every bail-out below goes through `defer`: `mv` and `chmod` prompt from here, since no rule
|
||||
# covers them, while the other verbs stay silent and leave the decision to the normal flow.
|
||||
guarded=0
|
||||
for verb in mv chmod; do
|
||||
runs_verb "$verb" "$cmd" && { guarded=1; break; }
|
||||
done
|
||||
defer() {
|
||||
[ "$guarded" = 1 ] && decide ask "$1"
|
||||
exit 0
|
||||
}
|
||||
|
||||
read -r -a toks <<< "$cmd"
|
||||
has_substitution "$cmd" && defer "command substitution in the command line"
|
||||
|
||||
# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp.
|
||||
# 0 iff the token is a literal path this hook may reason about. A glob never auto-allows: bash
|
||||
# expands it only after the hook has decided, so realpath sees the unexpanded pattern —
|
||||
# `/tmp/link*` canonicalizes to itself and passes, then expands onto a symlink whose target is
|
||||
# outside, and `cp` and `chmod` follow a command-line symlink, so that is a write to the target.
|
||||
# (guard-rm-outside-tmp.sh can allow globs because `rm` unlinks the symlink rather than following
|
||||
# it.) The charset holds none of the characters bash uses for quoting, expansion or separation.
|
||||
literal_path() {
|
||||
case "$1" in *[*?[]*) return 1 ;; esac
|
||||
[ -z "$(printf '%s' "$1" | tr -d 'A-Za-z0-9._/-')" ]
|
||||
}
|
||||
|
||||
# Prints the root class of a path token, then the path it resolved to on a second line,
|
||||
# resolving a relative one against the tracked working directory. Fails, printing nothing,
|
||||
# when the token is unsafe to reason about or lands outside every root.
|
||||
operand_class() {
|
||||
local t="$1" canon alt cls alt_cls=""
|
||||
literal_path "$t" || return 1
|
||||
case "$t" in
|
||||
/*) canon=$(realpath -m -- "$t" 2>/dev/null) ;;
|
||||
*) # A `cd` may fail at runtime and leave the command where it started, so a relative
|
||||
# operand has to land in the same root either way.
|
||||
[ -n "$seg_cwd" ] || return 1
|
||||
canon=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null)
|
||||
if [ -n "$alt_cwd" ]; then
|
||||
alt=$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)
|
||||
[ -n "$alt" ] || return 1
|
||||
alt_cls=$(path_class "$alt") || return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
[ -n "$canon" ] || return 1
|
||||
cls=$(path_class "$canon") || return 1
|
||||
[ -n "$alt_cls" ] && [ "$alt_cls" != "$cls" ] && return 1
|
||||
# Class and resolved path together: a caller runs this in a command substitution, so a global
|
||||
# set here would be set in that subshell and lost.
|
||||
printf '%s\n%s' "$cls" "$canon"
|
||||
}
|
||||
|
||||
# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. The archive
|
||||
# parser's stricter check; everything else goes through operand_class.
|
||||
under_tmp() {
|
||||
local t="$1" canon
|
||||
# Globs never auto-allow. Bash expands them only after this hook has decided, so realpath
|
||||
# sees the unexpanded pattern: `/tmp/link*` canonicalizes to itself and passes, then
|
||||
# expands onto a symlink whose target is outside /tmp. chmod and cp follow command-line
|
||||
# symlinks, so that is a write to the target. guard-rm-outside-tmp.sh can allow globs
|
||||
# because `rm` unlinks the symlink itself rather than following it.
|
||||
case "$t" in *[*?[]*) return 1 ;; esac
|
||||
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
|
||||
# Absolute only. Resolving a relative operand against the cwd makes any bare word look like
|
||||
# a safe path whenever the cwd is under /tmp, while the tool itself reads it as an option:
|
||||
# `tar P -xf ...` is --absolute-names, not ./P, and `cp /tmp/t -RL /tmp/o` is a
|
||||
# dereferencing recursive copy, not a file named -RL.
|
||||
literal_path "$t" || return 1
|
||||
case "$t" in /*) ;; *) return 1 ;; esac
|
||||
canon=$(realpath -m -- "$t" 2>/dev/null)
|
||||
[ -n "$canon" ] || return 1
|
||||
@@ -65,34 +126,16 @@ under_tmp() {
|
||||
return 1
|
||||
}
|
||||
|
||||
allow() {
|
||||
jq -nc --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Bare command word only; wrappers (`timeout cp`), env prefixes, and `/bin/cp` defer.
|
||||
# Options are an allowlist per command, so anything that changes how symlinks are followed
|
||||
# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while
|
||||
# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch
|
||||
# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate
|
||||
# such a symlink as a symlink instead, so no outside content is materialized.
|
||||
case "${toks[0]:-}" in
|
||||
mkdir) takes_mode=0; ok_opts='pv' ;;
|
||||
cp) takes_mode=0; ok_opts='rRvfnpa' ;;
|
||||
mv) takes_mode=0; ok_opts='vfn' ;;
|
||||
touch) takes_mode=0; ok_opts='acmv' ;;
|
||||
chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path
|
||||
tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
|
||||
unzip) ok_flags='oqnljvd'; val_flags='d' ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------- tar / unzip
|
||||
if [ -n "${ok_flags:-}" ]; then
|
||||
saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0
|
||||
i=1
|
||||
while [ "$i" -lt "${#toks[@]}" ]; do
|
||||
t="${toks[$i]}"
|
||||
# Proves one `tar` / `unzip` segment ($1 = the verb), whose tokens are in SEG_TOKS.
|
||||
check_archive_segment() {
|
||||
local verb="$1" ok_flags val_flags t flags val
|
||||
local saw_archive=0 saw_dest=0 extracting=0 listing=0 end_opts=0 i=1
|
||||
case "$verb" in
|
||||
tar) ok_flags='xctzjJavfC'; val_flags='fC' ;;
|
||||
unzip) ok_flags='oqnljvd'; val_flags='d' ;;
|
||||
esac
|
||||
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
|
||||
t="${SEG_TOKS[$i]}"
|
||||
i=$((i + 1))
|
||||
if [ "$end_opts" = 0 ]; then
|
||||
[ "$t" = "--" ] && { end_opts=1; continue; }
|
||||
@@ -101,18 +144,18 @@ if [ -n "${ok_flags:-}" ]; then
|
||||
flags="${t#-}"
|
||||
# Allowlist: a long option, -P/--absolute-names, --transform, -I and friends all
|
||||
# leave a residue here and defer rather than being enumerated as denials.
|
||||
[ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && exit 0
|
||||
[ -n "$(printf '%s' "$flags" | tr -d "$ok_flags")" ] && defer "unrecognized option \`$t\`"
|
||||
case "$flags" in *x*) extracting=1 ;; esac
|
||||
case "${toks[0]}$flags" in unzip*[lv]*) listing=1 ;; esac
|
||||
case "$verb$flags" in unzip*[lv]*) listing=1 ;; esac
|
||||
# A flag consuming the next token must be alone in its bundle's final position
|
||||
# (`-xzf a.tar`), else the token it eats is ambiguous.
|
||||
case "${flags%?}" in *[$val_flags]*) exit 0 ;; esac
|
||||
case "${flags%?}" in *[$val_flags]*) defer "ambiguous option bundle \`$t\`" ;; esac
|
||||
case "${flags: -1}" in
|
||||
[$val_flags])
|
||||
val="${toks[$i]:-}"
|
||||
val="${SEG_TOKS[$i]:-}"
|
||||
i=$((i + 1))
|
||||
[ -n "$val" ] || exit 0
|
||||
under_tmp "$val" || exit 0
|
||||
[ -n "$val" ] || defer "option \`$t\` has no value"
|
||||
under_tmp "$val" || defer "\`$val\` is outside /tmp"
|
||||
case "${flags: -1}" in
|
||||
f) saw_archive=1 ;;
|
||||
C | d) saw_dest=1 ;;
|
||||
@@ -126,54 +169,153 @@ if [ -n "${ok_flags:-}" ]; then
|
||||
# Positional. For tar these are sources (create) or member names (extract); for unzip the
|
||||
# first is the archive. Requiring every one under /tmp is conservative for member names,
|
||||
# which are not filesystem paths — those defer rather than being wrongly allowed.
|
||||
under_tmp "$t" || exit 0
|
||||
[ "${toks[0]}" = "unzip" ] && saw_archive=1
|
||||
under_tmp "$t" || defer "\`$t\` is outside /tmp"
|
||||
[ "$verb" = "unzip" ] && saw_archive=1
|
||||
done
|
||||
|
||||
[ "$saw_archive" = 1 ] || exit 0 # tar without -f reads a tape/stdin; unzip needs an archive
|
||||
# tar without -f reads a tape/stdin; unzip needs an archive
|
||||
[ "$saw_archive" = 1 ] || defer "no archive operand"
|
||||
# Writes land relative to the working directory unless a destination was given. `unzip -l`
|
||||
# and `-v` only list, so they need no destination.
|
||||
if [ "$extracting" = 1 ] || { [ "${toks[0]}" = "unzip" ] && [ "$listing" = 0 ]; }; then
|
||||
[ "$saw_dest" = 1 ] || under_tmp "${cwd:-$PWD}" || exit 0
|
||||
if [ "$extracting" = 1 ] || { [ "$verb" = "unzip" ] && [ "$listing" = 0 ]; }; then
|
||||
# An extraction with no destination lands in the working directory. Word splitting cannot
|
||||
# tell a `cd` inside a quoted string from one the shell runs, and believing a false one
|
||||
# would put an archive's members in the checkout, so once any `cd` is in the line only an
|
||||
# explicit destination will do.
|
||||
[ "$saw_dest" = 1 ] \
|
||||
|| { [ "$saw_cd" = 0 ] && [ -n "$seg_cwd" ] && under_tmp "$seg_cwd"; } \
|
||||
|| defer "extraction target is outside /tmp"
|
||||
fi
|
||||
allow "archive paths and extraction target are under /tmp"
|
||||
fi
|
||||
}
|
||||
|
||||
# ------------------------------------------- mkdir / cp / mv / touch / chmod
|
||||
path_operand=0
|
||||
seen_mode=0
|
||||
end_opts=0
|
||||
i=1
|
||||
while [ "$i" -lt "${#toks[@]}" ]; do
|
||||
t="${toks[$i]}"
|
||||
i=$((i + 1))
|
||||
# Proves one `mkdir` / `cp` / `mv` / `touch` / `chmod` segment ($1 = the verb), whose tokens
|
||||
# are in SEG_TOKS.
|
||||
check_fileops_segment() {
|
||||
local verb="$1" takes_mode ok_opts t cls resolved seen_class=""
|
||||
local path_operand=0 seen_mode=0 end_opts=0 i=1 rel_operand=0
|
||||
local -a ops=()
|
||||
# Options are an allowlist per command, so anything that changes how symlinks are followed
|
||||
# defers instead of needing enumeration. `cp -L` / `-H` matter most: they dereference while
|
||||
# recursing, which copies the CONTENT of a symlink target from outside /tmp into a scratch
|
||||
# dir that `Read(/tmp/**)` then exposes. Plain `-r` and `-a` (which implies `-d`) recreate
|
||||
# such a symlink as a symlink instead, so no outside content is materialized.
|
||||
case "$verb" in
|
||||
mkdir) takes_mode=0; ok_opts='pv' ;;
|
||||
cp) takes_mode=0; ok_opts='rRvfnpa' ;;
|
||||
mv) takes_mode=0; ok_opts='vfn' ;;
|
||||
touch) takes_mode=0; ok_opts='acmv' ;;
|
||||
chmod) takes_mode=1; ok_opts='Rvfc' ;; # chmod's first operand is a mode, not a path
|
||||
esac
|
||||
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
|
||||
t="${SEG_TOKS[$i]}"
|
||||
i=$((i + 1))
|
||||
|
||||
if [ "$end_opts" = 0 ]; then
|
||||
[ "$t" = "--" ] && { end_opts=1; continue; }
|
||||
# Checked at any position, not just before the first operand: GNU utils permute, so
|
||||
# `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion.
|
||||
case "$t" in
|
||||
-?*)
|
||||
# Allowlist: long options and the dereferencing flags leave a residue and defer.
|
||||
[ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && exit 0
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [ "$end_opts" = 0 ]; then
|
||||
[ "$t" = "--" ] && { end_opts=1; continue; }
|
||||
# Checked at any position, not just before the first operand: GNU utils permute, so
|
||||
# `cp /tmp/tree -RL /tmp/out` still enables dereferencing recursion.
|
||||
case "$t" in
|
||||
-?*)
|
||||
# Allowlist: long options and the dereferencing flags leave a residue and defer.
|
||||
[ -n "$(printf '%s' "${t#-}" | tr -d "$ok_opts")" ] && defer "unrecognized option \`$t\`"
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# chmod: consume the mode operand without a path check. Octal, or symbolic clauses.
|
||||
if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then
|
||||
case "$t" in
|
||||
[0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
|
||||
*) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || exit 0 ;;
|
||||
esac
|
||||
seen_mode=1
|
||||
continue
|
||||
fi
|
||||
# chmod: consume the mode operand without a path check. Octal, or symbolic clauses.
|
||||
if [ "$takes_mode" = 1 ] && [ "$seen_mode" = 0 ]; then
|
||||
case "$t" in
|
||||
[0-7] | [0-7][0-7] | [0-7][0-7][0-7] | [0-7][0-7][0-7][0-7]) ;;
|
||||
*) printf '%s' "$t" | grep -Eq '^[ugoa]*[+=-][rwxXst]*(,[ugoa]*[+=-][rwxXst]*)*$' || defer "unrecognized mode \`$t\`" ;;
|
||||
esac
|
||||
seen_mode=1
|
||||
continue
|
||||
fi
|
||||
|
||||
under_tmp "$t" || exit 0
|
||||
path_operand=1
|
||||
resolved=$(operand_class "$t") || defer "\`$t\` is outside /tmp and not inside a git checkout in \$HOME"
|
||||
cls="${resolved%%$'\n'*}"
|
||||
# Every operand of one operation stays in one root: see the exfiltration note above.
|
||||
[ -n "$seen_class" ] && [ "$cls" != "$seen_class" ] && defer "\`$t\` puts this $verb across two roots"
|
||||
seen_class="$cls"
|
||||
ops+=("${resolved#*$'\n'}")
|
||||
case "$t" in /*) ;; *) rel_operand=1 ;; esac
|
||||
path_operand=1
|
||||
done
|
||||
|
||||
[ "$path_operand" = 1 ] || defer "no path operand"
|
||||
|
||||
# In directory form the command writes a path it does not name: `cp x dir` writes `dir/x`,
|
||||
# and `cp` follows that child when it is a symlink — this checkout is full of them, every
|
||||
# `*_ee.rs` pointing into the sibling EE repo. Deriving that child would mean reproducing
|
||||
# which name the tool picks (the operand as written, not as resolved — a symlinked source
|
||||
# keeps its own name) and how deep `-r` recurses. The form is left unproved instead.
|
||||
case "$verb" in
|
||||
cp | mv)
|
||||
[ "${#ops[@]}" -ge 2 ] || return 0
|
||||
# Whether the destination is an existing directory is itself a question about which of
|
||||
# the two candidate working directories the command ran in, and only one of them is in
|
||||
# `ops`. A `cd` that fails at runtime would otherwise let the form through: the
|
||||
# destination resolved against the directory the command never reached is some path that
|
||||
# does not exist, while the one it actually ran in is a directory full of symlinks.
|
||||
[ -n "$alt_cwd" ] && [ "$rel_operand" = 1 ] \
|
||||
&& defer "a relative operand after a \`cd\` lands in one of two directories"
|
||||
[ -d "${ops[-1]}" ] \
|
||||
&& defer "\`${ops[-1]}\` already exists as a directory, so this $verb writes a path it does not name"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
split_segments "$cmd"
|
||||
seg_cwd="${cwd:-$PWD}"
|
||||
alt_cwd="" # where a `cd` that failed would have left the command
|
||||
saw_cd=0 # a `cd` moved the working directory somewhere
|
||||
proved=0 # how many ops came out inside a single root
|
||||
only_ours=1 # ... and nothing else shares the command line
|
||||
|
||||
for seg in "${SEGMENTS[@]}"; do
|
||||
segment_tokens "$seg"
|
||||
case "${SEG_TOKS[0]:-}" in
|
||||
"") continue ;;
|
||||
mkdir | cp | mv | touch | chmod)
|
||||
check_fileops_segment "${SEG_TOKS[0]}"
|
||||
proved=$((proved + 1))
|
||||
continue
|
||||
;;
|
||||
tar | unzip)
|
||||
check_archive_segment "${SEG_TOKS[0]}"
|
||||
proved=$((proved + 1))
|
||||
continue
|
||||
;;
|
||||
cd)
|
||||
# A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
|
||||
# operand points, to one of the two candidates `apply_cd` describes.
|
||||
if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
|
||||
alt_cwd="$seg_cwd"
|
||||
seg_cwd="$new_cwd"
|
||||
else
|
||||
# Not the harmless segment an allow assumes: whatever this guard could not account for
|
||||
# may be a redirect, and a redirect writes. Leave the line to the normal flow.
|
||||
seg_cwd="" alt_cwd=""
|
||||
only_ours=0
|
||||
fi
|
||||
saw_cd=1
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
# Some other command shares the line. If an `mv` or `chmod` runs inside it after all — behind
|
||||
# a wrapper, an env prefix or a path — this hook cannot say what it writes to.
|
||||
for verb in mv chmod; do
|
||||
segment_runs_verb "$verb" "$seg" && defer "$verb is not the leading command word in \`$seg\`"
|
||||
done
|
||||
only_ours=0
|
||||
done
|
||||
|
||||
[ "$path_operand" = 1 ] || exit 0
|
||||
allow "every path operand is under /tmp"
|
||||
# Exactly one write per line. Each segment is proved against the filesystem as it stands now,
|
||||
# and an earlier write can change what a later operand means: `cp -r /tmp/tree /tmp/live` that
|
||||
# recreates a symlink out of /tmp turns `/tmp/live/link` — a path under /tmp when this ran —
|
||||
# into a write through that symlink. Deletes compose safely and guard-rm-outside-tmp.sh allows
|
||||
# several, because `rm` unlinks a symlink rather than following it.
|
||||
[ "$proved" -ge 1 ] || exit 0
|
||||
[ "$only_ours" = 1 ] && [ "$proved" = 1 ] && decide allow "every path operand is inside a single root"
|
||||
exit 0
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse guard for `rm`: auto-allow ONLY a single, plain, single-line `rm` whose every
|
||||
# operand is a whitelisted target — under /tmp, or inside a git working tree located in $HOME
|
||||
# (a version-controlled project dir). Anything else makes no decision (exit 0) and falls back
|
||||
# to the normal permission flow, where the `Bash(rm:*)` ask rule prompts (classifier as a
|
||||
# backstop).
|
||||
# PreToolUse guard for `rm`: auto-allow deletes whose every operand is a whitelisted target —
|
||||
# under /tmp, or inside a git working tree located in $HOME (a version-controlled project dir).
|
||||
# Any other command that runs `rm` gets an explicit `ask`, which is the ordinary permission
|
||||
# prompt and the only one `rm` gets (see lib-guarded-verb.sh); a command that runs no `rm` at
|
||||
# all makes no decision (exit 0).
|
||||
#
|
||||
# The git-tree allowance trades on "this is a project under version control" being lower-stakes
|
||||
# than a delete elsewhere — NOT on full recoverability: committed content is restorable via git,
|
||||
# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history
|
||||
# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff.
|
||||
# The command is read one segment at a time, so chaining and line breaks carry no weight of
|
||||
# their own: `rm -f /tmp/a && rm -rf /tmp/b` is two deletes, each proved on its own operands.
|
||||
# A decision covers the whole command line, so `allow` is emitted only when every segment is
|
||||
# an `rm` this guard proved or a `cd` it could resolve. A line that mixes a proven `rm` with
|
||||
# some other command makes no decision instead and leaves that line to the normal permission
|
||||
# flow: the delete is not what needed a prompt, and waving the rest of the line through with
|
||||
# it would turn a trailing `rm -f /tmp/x` into a way to auto-approve anything.
|
||||
#
|
||||
# Deny-by-default: every token must consist only of a safe character set (alphanumerics,
|
||||
# `. _ / -` and glob chars `* ? [ ]`). That set contains none of the characters bash uses for
|
||||
@@ -17,15 +20,16 @@
|
||||
# and existing symlinks (so a symlink out of the allowed roots is caught), and a wildcard in a
|
||||
# non-final path segment is refused because it can expand through a symlink realpath can't see.
|
||||
#
|
||||
# The git-repo allowance covers targets inside a git working tree under $HOME, and the tree's
|
||||
# own root folder only when it is a linked worktree (`.git` is a pointer file, so history in
|
||||
# the main repo survives); a primary checkout's root (`.git` is a history dir) and any `.git`
|
||||
# path are never auto-allowed. Globs auto-allow only under /tmp — elsewhere their expansion
|
||||
# Which targets those two roots cover, and the tradeoff they rest on, is `path_class` in
|
||||
# lib-guarded-verb.sh. Globs auto-allow only under /tmp — elsewhere their expansion
|
||||
# could reach `.git` or a dotfile the literal checks never see. Relative operands resolve
|
||||
# against the command's cwd (from the hook input). A PreToolUse `allow` overrides the ask rule.
|
||||
# against the working directory the command runs from, which a `cd` in an earlier segment
|
||||
# moves; once a `cd` is one this guard cannot resolve, that directory is unknown and a
|
||||
# relative operand can no longer be proved.
|
||||
#
|
||||
# Assumes GNU `realpath` (-m) and `jq`, both present in this repo's Linux dev env.
|
||||
set -uo pipefail
|
||||
. "${BASH_SOURCE[0]%/*}/lib-guarded-verb.sh"
|
||||
|
||||
input=$(cat)
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
@@ -33,74 +37,104 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
||||
[ -z "$cmd" ] && exit 0
|
||||
cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null)
|
||||
|
||||
# A newline separates commands, and the tokenizer below only reads the first line — defer.
|
||||
case "$cmd" in *$'\n'*) exit 0 ;; esac
|
||||
|
||||
read -r -a toks <<< "$cmd"
|
||||
# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer.
|
||||
[ "${toks[0]:-}" = "rm" ] || exit 0
|
||||
|
||||
# 0 (allow) iff the canonical path is an auto-allowable rm target: under /tmp, or strictly
|
||||
# inside a git working tree located under $HOME. The walk stops at $HOME, so a dotfiles repo at
|
||||
# ~ can't make all of $HOME deletable, and top-level ~ files stay protected.
|
||||
allowed_target() {
|
||||
local canon="$1" d root=""
|
||||
case "$canon" in /tmp/?*) return 0 ;; esac
|
||||
[ -n "${HOME:-}" ] || return 1
|
||||
case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac
|
||||
case "$canon" in *"/.git" | *"/.git/"*) return 1 ;; esac # protect history, not recoverable
|
||||
d="$canon"
|
||||
while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do
|
||||
[ -e "$d/.git" ] && { root="$d"; break; }
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
[ -n "$root" ] || return 1 # not inside a git working tree under $HOME
|
||||
if [ "$canon" = "$root" ]; then
|
||||
# Deleting the repo root folder itself: allow only for a linked worktree, whose `.git` is
|
||||
# a file/pointer so the history lives in the main repo and survives. A primary checkout's
|
||||
# `.git` is a directory holding the history, so deleting it is unrecoverable — defer.
|
||||
[ -f "$root/.git" ] && return 0
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
# Every bail-out below goes through `defer`, so the forms this guard refuses to reason about —
|
||||
# wrapped, quoted, expanded — still reach the user as a prompt whenever an `rm` runs among them.
|
||||
runs_verb rm "$cmd" && guarded=1 || guarded=0
|
||||
defer() {
|
||||
[ "$guarded" = 1 ] && decide ask "$1"
|
||||
exit 0
|
||||
}
|
||||
|
||||
had_operand=0
|
||||
end_opts=0
|
||||
i=1
|
||||
while [ "$i" -lt "${#toks[@]}" ]; do
|
||||
t="${toks[$i]}"
|
||||
i=$((i + 1))
|
||||
# Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm`
|
||||
# can't slip past): any character outside the safe set makes it unsafe to reason about.
|
||||
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && exit 0
|
||||
# A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name`
|
||||
# into an operand — never a real option, so defer.
|
||||
case "$t" in -*[*?[]*) exit 0 ;; esac
|
||||
if [ "$end_opts" = 0 ]; then
|
||||
[ "$t" = "--" ] && { end_opts=1; continue; }
|
||||
# Skip real options only before the first operand. A bare `-` is a filename, and under
|
||||
# POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name`
|
||||
# is a filename too — validate it rather than skipping it.
|
||||
if [ "$had_operand" = 0 ]; then
|
||||
case "$t" in -?*) continue ;; esac
|
||||
has_substitution "$cmd" && defer "command substitution in the command line"
|
||||
|
||||
# Proves one `rm` segment, whose tokens are in SEG_TOKS with `rm` at index 0, resolving relative
|
||||
# operands against $seg_cwd. Returns only once every operand is an auto-allowable target;
|
||||
# anything it cannot prove defers instead.
|
||||
check_rm_segment() {
|
||||
local i=1 t canon candidates had_operand=0 end_opts=0
|
||||
while [ "$i" -lt "${#SEG_TOKS[@]}" ]; do
|
||||
t="${SEG_TOKS[$i]}"
|
||||
i=$((i + 1))
|
||||
# Whitelist every token (flags included, so an operator hidden in a flag like `-rf;rm`
|
||||
# can't slip past): any character outside the safe set makes it unsafe to reason about.
|
||||
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/*?[]-')" ] && defer "unsafe characters in \`$t\`"
|
||||
# A glob in an option-looking token (`-[-]`) can expand to `--` and turn a later `-name`
|
||||
# into an operand — never a real option, so defer.
|
||||
case "$t" in -*[*?[]*) defer "glob inside the option \`$t\`" ;; esac
|
||||
if [ "$end_opts" = 0 ]; then
|
||||
[ "$t" = "--" ] && { end_opts=1; continue; }
|
||||
# Skip real options only before the first operand. A bare `-` is a filename, and under
|
||||
# POSIXLY_CORRECT GNU rm stops option parsing at the first operand, so a later `-name`
|
||||
# is a filename too — validate it rather than skipping it.
|
||||
if [ "$had_operand" = 0 ]; then
|
||||
case "$t" in -?*) continue ;; esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
had_operand=1
|
||||
# No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink
|
||||
# realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine.
|
||||
case "$t" in */*) case "${t%/*}" in *[*?[]*) exit 0 ;; esac ;; esac
|
||||
case "$t" in
|
||||
/*) canon=$(realpath -m -- "$t" 2>/dev/null) ;;
|
||||
*) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;;
|
||||
had_operand=1
|
||||
# No wildcard in a non-final path segment (`a/*/b`): it can expand through a symlink
|
||||
# realpath can't see. A slashless glob (`*.rs`) is a final-segment match — fine.
|
||||
case "$t" in */*) case "${t%/*}" in *[*?[]*) defer "glob in a non-final segment of \`$t\`" ;; esac ;; esac
|
||||
# A relative operand has as many candidate paths as the command has candidate working
|
||||
# directories, and every one of them has to be auto-allowable: a `cd` that fails at runtime
|
||||
# leaves the delete running in the directory it started in.
|
||||
case "$t" in
|
||||
/*) candidates=$(realpath -m -- "$t" 2>/dev/null) ;;
|
||||
*) [ -n "$seg_cwd" ] || defer "\`$t\` is relative to a working directory this guard cannot pin down"
|
||||
candidates=$(realpath -m -- "$seg_cwd/$t" 2>/dev/null)
|
||||
[ -n "$alt_cwd" ] && candidates="$candidates
|
||||
$(realpath -m -- "$alt_cwd/$t" 2>/dev/null)"
|
||||
;;
|
||||
esac
|
||||
while IFS= read -r canon; do
|
||||
[ -n "$canon" ] || defer "cannot resolve \`$t\`"
|
||||
# A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its
|
||||
# expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the
|
||||
# literal-path checks never see — so require literal operands in git repos.
|
||||
case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) defer "glob \`$t\` is outside /tmp" ;; esac ;; esac
|
||||
path_class "$canon" >/dev/null || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME"
|
||||
done <<< "$candidates"
|
||||
done
|
||||
[ "$had_operand" = 1 ] || defer "no operand"
|
||||
}
|
||||
|
||||
split_segments "$cmd"
|
||||
seg_cwd="${cwd:-$PWD}"
|
||||
alt_cwd="" # where a `cd` that failed would have left the command
|
||||
saw_cd=0
|
||||
proved=0 # at least one `rm` segment came out auto-allowable
|
||||
only_ours=1 # ... and nothing else shares the command line
|
||||
|
||||
for seg in "${SEGMENTS[@]}"; do
|
||||
segment_tokens "$seg"
|
||||
case "${SEG_TOKS[0]:-}" in
|
||||
"") continue ;;
|
||||
rm)
|
||||
check_rm_segment
|
||||
proved=1
|
||||
continue
|
||||
;;
|
||||
cd)
|
||||
# A `cd` writes nothing, so it never blocks an allow; it only moves where a later relative
|
||||
# operand points, to one of the two candidates `apply_cd` describes.
|
||||
if [ "$saw_cd" = 0 ] && new_cwd=$(apply_cd "$seg_cwd" "${SEG_TOKS[@]:1}"); then
|
||||
alt_cwd="$seg_cwd"
|
||||
seg_cwd="$new_cwd"
|
||||
else
|
||||
# Not the harmless segment an allow assumes: whatever this guard could not account for
|
||||
# may be a redirect, and a redirect writes. Leave the line to the normal flow.
|
||||
seg_cwd="" alt_cwd=""
|
||||
only_ours=0
|
||||
fi
|
||||
saw_cd=1
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
[ -n "$canon" ] || exit 0
|
||||
# A glob may auto-allow only under /tmp, where everything is deletable. Elsewhere its
|
||||
# expansion could match `.git`, a dotfile like `.*`, or a nested checkout root that the
|
||||
# literal-path checks never see — so require literal operands in git repos.
|
||||
case "$t" in *[*?[]*) case "$canon" in /tmp/?*) ;; *) exit 0 ;; esac ;; esac
|
||||
allowed_target "$canon" || exit 0
|
||||
# Some other command shares the line. If an `rm` runs inside it after all — behind a wrapper,
|
||||
# an env prefix or a path — this guard cannot say what it deletes.
|
||||
segment_runs_verb rm "$seg" && defer "rm is not the leading command word in \`$seg\`"
|
||||
only_ours=0
|
||||
done
|
||||
|
||||
[ "$had_operand" = 1 ] || exit 0
|
||||
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:"rm operands are under /tmp or inside a git checkout in $HOME"}}'
|
||||
[ "$proved" = 1 ] || exit 0
|
||||
[ "$only_ours" = 1 ] && decide allow 'rm operands are under /tmp or inside a git checkout in $HOME'
|
||||
exit 0
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sourced by the PreToolUse guards; not a hook itself.
|
||||
#
|
||||
# A permission rule beats a hook: an `ask` rule prompts whatever a PreToolUse hook returns, which
|
||||
# makes the hook's `allow` dead weight. So settings.json carries no `ask` rule for `rm`, `mv` or
|
||||
# `chmod`, and the guards own both halves — `allow` what they can prove safe, `ask` for the rest.
|
||||
# Removing a guard's `ask` path therefore removes that verb's prompt entirely.
|
||||
#
|
||||
# `set -f` is global to the sourcing script so that the unquoted word split in runs_verb cannot
|
||||
# expand a glob operand against the filesystem. Neither guard relies on pathname expansion.
|
||||
set -f
|
||||
|
||||
# 0 iff <text> ($1) starts with a command that only reads its input. An allowlist, because the
|
||||
# opposite — naming the shells to avoid — would have to be complete: an unlisted one (`ash`,
|
||||
# `rbash`, `busybox sh`) executes the body while the guard calls it data. Unrecognized here only
|
||||
# costs a prompt. Text with no command word in it is not evidence of a reader either.
|
||||
reads_only() {
|
||||
local w
|
||||
for w in $1; do
|
||||
w="${w//[\"\'\\]/}"
|
||||
w="${w%%<<*}" # a redirect needs no space: `cat<<EOF`
|
||||
case "$w" in "" | -* | *=* | [0-9]* | '>'* | '<'*) continue ;; esac
|
||||
case "${w##*/}" in
|
||||
cat | tee | head | tail | grep | sed | awk | sort | uniq | wc | cut | diff | tr \
|
||||
| jq | yq | gh | git | base64 | column | envsubst | python | python3 | node \
|
||||
| psql | mysql | sqlite3 | wmill) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# A heredoc body is data rather than commands only when its delimiter is quoted and nothing
|
||||
# executes it; a rule doesn't match a verb inside such a body, and a PR body would otherwise
|
||||
# prompt for every `rm` in its text. Dropping one needs all of that, a delimiter that could
|
||||
# really open a heredoc, and a terminator line — failing any part, nothing is dropped.
|
||||
strip_heredoc_bodies() {
|
||||
local -a lines=()
|
||||
local line delim rest after trimmed piped quoted i j n
|
||||
while IFS= read -r line; do lines+=("$line"); done <<< "$1"
|
||||
n=${#lines[@]}
|
||||
i=0
|
||||
while [ "$i" -lt "$n" ]; do
|
||||
line="${lines[$i]}"
|
||||
printf '%s\n' "$line"
|
||||
i=$((i + 1))
|
||||
# A `#` opens a comment, and a comment opens no heredoc — including mid-line, as in
|
||||
# `echo hi # cat <<EOF`. Cutting there also discards a `#` that is really part of a word or
|
||||
# a string, which at worst leaves a real body to be scanned: an extra prompt, never a lost one.
|
||||
line="${line%%'#'*}"
|
||||
case "$line" in *'<<'*) ;; *) continue ;; esac
|
||||
rest="${line#*<<}"
|
||||
rest="${rest#-}" # <<- strips leading tabs from the body
|
||||
rest="${rest#"${rest%%[![:space:]]*}"}"
|
||||
delim="${rest%%[[:space:]]*}"
|
||||
# Whatever follows the delimiter word decides whether this line could open a heredoc at
|
||||
# all. Only a redirect or a pipe can (`cat <<EOF > f`); prose after it means the `<<` sits
|
||||
# inside a string (`echo "cat <<EOF and more"`), and dropping down to a line that happens
|
||||
# to match would discard the real commands in between. A quote anywhere in the remainder
|
||||
# says the same thing, since `echo "cat <<EOF > f"` ends its redirect-looking text with the
|
||||
# closing quote. That also refuses `cat <<EOF > "f"`, a real heredoc, which only over-prompts.
|
||||
after="${rest#"$delim"}"
|
||||
after="${after#"${after%%[![:space:]]*}"}"
|
||||
case "$after" in
|
||||
*[\"\'\\]*) continue ;;
|
||||
"" | '>'* | '<'* | '|'* | [0-9]'>'* | [0-9]'<'*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
# A real delimiter is a bare word or one wholly quoted (`<<'EOF'`, `<<\EOF`); a stray quote
|
||||
# left in it means the `<<` was quoted prose.
|
||||
quoted=0
|
||||
case "$delim" in
|
||||
\'*\' | \"*\") delim="${delim:1:${#delim}-2}" quoted=1 ;;
|
||||
\\?*) delim="${delim#\\}" quoted=1 ;;
|
||||
esac
|
||||
case "$delim" in
|
||||
[A-Za-z_]*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
case "$delim" in *[!A-Za-z0-9_]*) continue ;; esac
|
||||
# Only a quoted delimiter makes the body inert. Unquoted, the shell expands it before the
|
||||
# consumer ever sees it, so a `$(rm -rf ~)` written in the body runs whatever reads it.
|
||||
[ "$quoted" = 1 ] || continue
|
||||
# Two commands can see this body: the one the `<<` belongs to, and anything it is then piped
|
||||
# into. The first is whatever was started last before the `<<`, so splitting the text there
|
||||
# on separators and substitution openers and taking the final piece finds `cat` in
|
||||
# `--title "fix(agents): …" --body "$(cat <<`, without the title's parenthesis standing in
|
||||
# for it. A line continuation (`bash \` then `<<'EOF'`) leaves that piece empty, which is
|
||||
# not evidence of a reader and so keeps the body.
|
||||
reads_only "$(printf '%s' "${line%%<<*}" | tr ';&|()`' '\n' | grep -v '^[[:space:]]*$' | tail -1)" || continue
|
||||
piped="$after"
|
||||
while :; do
|
||||
case "$piped" in *'|'*) ;; *) break ;; esac
|
||||
piped="${piped#*|}"
|
||||
reads_only "${piped%%|*}" || continue 2
|
||||
done
|
||||
j="$i"
|
||||
while [ "$j" -lt "$n" ]; do
|
||||
trimmed="${lines[$j]#"${lines[$j]%%[![:space:]]*}"}"
|
||||
[ "$trimmed" = "$delim" ] && break
|
||||
j=$((j + 1))
|
||||
done
|
||||
[ "$j" -lt "$n" ] && i=$((j + 1))
|
||||
done
|
||||
}
|
||||
|
||||
# 0 iff <verb> ($1) runs as a command word in <segment> ($2), which must already be one
|
||||
# segment (no separator left in it). Wrapper, env-prefix and `/bin/<verb>` forms all count.
|
||||
segment_runs_verb() {
|
||||
local verb="$1" w wrapped=0
|
||||
for w in $2; do
|
||||
# The shell strips quotes and backslashes before it looks up the command, so `'rm'` and
|
||||
# `r\m` run rm and have to compare equal to it.
|
||||
w="${w//[\"\'\\]/}"
|
||||
case "$w" in
|
||||
"$verb" | */"$verb") return 0 ;;
|
||||
*=*) ;; # leading env assignment
|
||||
-* | *'>'* | *'<'*) ;; # a flag, or a leading redirect
|
||||
[0-9]*) [ "$wrapped" = 1 ] || break ;; # a wrapper's duration, not `1:` in prose
|
||||
'!' | '{' | '}' | if | then | elif | else | while | until | do) ;; # never the command
|
||||
timeout | time | nice | nohup | stdbuf | command | builtin | noglob | xargs | sudo | env)
|
||||
wrapped=1 ;;
|
||||
# A wrapper's option value is indistinguishable from a command name (`stdbuf -o L rm`),
|
||||
# so past a wrapper the scan runs to the end of the segment instead of stopping at the
|
||||
# first ordinary word. Before one, that word is the command and the verb cannot follow
|
||||
# it. Nothing bounds the scan: a wrapper takes unboundedly many operands
|
||||
# (`env -u A -u B ...`), and any cutoff — a word count, or stopping at the first quoted
|
||||
# word — drops the prompt for a real `sudo -u 'root' rm`. Prose after a wrapper is the
|
||||
# price, and it only over-prompts.
|
||||
*) [ "$wrapped" = 1 ] || break ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Splits <command> ($1) into its command segments, into the global array SEGMENTS. Every guard
|
||||
# reasons one segment at a time, so `a && b` is two commands here rather than one unparsable
|
||||
# blob, and a newline is a separator like any other.
|
||||
#
|
||||
# The split set carries more than `; & |` and newlines: `$(`, backticks and `( )` open a nested
|
||||
# command, and a separator that only ended statements would read `echo $(rm -rf ~)` as an
|
||||
# `echo`. Braces are handled as words rather than separators, since splitting on them cuts
|
||||
# `xargs -I {} … rm` in half and strands the `rm` in a segment that no longer knows a wrapper
|
||||
# preceded it.
|
||||
#
|
||||
# `tr` and not `${1//[...]}`: a `}` inside the bracket expression closes the expansion itself,
|
||||
# which silently leaves the command unsplit and every separator unseen.
|
||||
split_segments() {
|
||||
local seg
|
||||
SEGMENTS=()
|
||||
while IFS= read -r seg; do SEGMENTS+=("$seg"); done <<< "$(strip_heredoc_bodies "$1" | tr ';&|()`' '\n')"
|
||||
}
|
||||
|
||||
# 0 iff <command> ($1) carries a command substitution outside a heredoc body. A substitution is
|
||||
# concatenated into the word it sits in, and splitting on its opener cuts that word in half:
|
||||
# `/tmp/a/`printf ../../etc`` would be proved as `/tmp/a/`, with the traversal validated as an
|
||||
# unrelated segment. Nothing here can evaluate it, so a guard proves nothing about such a
|
||||
# command. Heredoc bodies are excepted — those are data the split has already dropped.
|
||||
has_substitution() {
|
||||
case "$(strip_heredoc_bodies "$1")" in
|
||||
*'$('* | *'`'*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Reads <segment> ($1) into the global array SEG_TOKS, dropping the shell keywords that can
|
||||
# precede a command word so that `then rm -rf x` is analyzed as the `rm` it runs. Word
|
||||
# splitting only: quotes are left in the token and fail the guards' charset check downstream,
|
||||
# which is what keeps `rm -rf "$HOME/x"` unprovable.
|
||||
segment_tokens() {
|
||||
SEG_TOKS=()
|
||||
read -r -a SEG_TOKS <<< "$1"
|
||||
while [ "${#SEG_TOKS[@]}" -gt 0 ]; do
|
||||
case "${SEG_TOKS[0]}" in
|
||||
'!' | '{' | '}' | if | then | elif | else | while | until | do) SEG_TOKS=("${SEG_TOKS[@]:1}") ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Prints the directory a `cd` lands in, given the current one ($1) and the tokens after the
|
||||
# `cd` ($2...). Fails, printing nothing, when the destination cannot be resolved — a variable,
|
||||
# `-`, an option, a relative path, no operand at all (`cd` alone is $HOME), or more than one.
|
||||
#
|
||||
# Resolving says nothing about whether the `cd` will SUCCEED: the destination may not exist, and
|
||||
# `;` runs the next command anyway, leaving it in the directory it started in. So a caller may
|
||||
# never treat this as the working directory outright — it is one of two candidates, and a
|
||||
# relative operand has to be provable against the one the command started in as well. That also
|
||||
# makes a `cd` word splitting invented out of quoted text harmless: it can only add a candidate,
|
||||
# never drop one. Past the first `cd` the branching outruns two candidates, so a caller that
|
||||
# sees a second gives up on relative operands entirely.
|
||||
apply_cd() {
|
||||
local cwd="$1" t
|
||||
shift
|
||||
[ "$#" -eq 1 ] || return 1
|
||||
t="$1"
|
||||
[ -n "$(printf '%s' "$t" | tr -d 'A-Za-z0-9._/-')" ] && return 1
|
||||
# Absolute only. A relative destination is not `$cwd/$t`: the shell searches $CDPATH first,
|
||||
# so `cd ssh` may land in /etc/ssh, and this cannot see the caller's $CDPATH to rule it out.
|
||||
case "$t" in /*) ;; *) return 1 ;; esac
|
||||
realpath -m -- "$t" 2>/dev/null
|
||||
}
|
||||
|
||||
# Prints the class of a canonical path and returns 0: `tmp` for one strictly under /tmp, or
|
||||
# `repo:<root>` for one strictly inside the git working tree at <root>, itself under $HOME.
|
||||
# Fails, printing nothing, for anything else — those are the only roots the guards are willing
|
||||
# to touch unprompted. The root is part of the class so that a caller pairing two operands can
|
||||
# tell one checkout from another: sibling repos are separate permission boundaries, not one.
|
||||
#
|
||||
# The `repo` class trades on "this is a project under version control" being lower-stakes than
|
||||
# the same act elsewhere — NOT on full recoverability: committed content is restorable via git,
|
||||
# but untracked / .gitignore'd / uncommitted content, and an independent nested repo's history
|
||||
# under a recursively-deleted parent, are NOT. Accepted as a deliberate convenience tradeoff.
|
||||
#
|
||||
# The walk stops at $HOME, so a dotfiles repo at ~ can't put all of $HOME in a class, and
|
||||
# top-level ~ files stay out of one. A working tree's own root folder counts only when it is a
|
||||
# linked worktree, whose `.git` is a pointer file so the history lives in the main repo and
|
||||
# survives; a primary checkout's `.git` is a directory holding the history itself, so losing it
|
||||
# is unrecoverable.
|
||||
#
|
||||
# Some paths are in no class in any root, /tmp included. Git history, and the agent's own guards
|
||||
# and settings, because removing those is what removes the prompt on everything else. And every
|
||||
# path `.claude/settings.json` refuses to read — `.env`, `secrets/`, `*.pem`, `*.key`,
|
||||
# `credentials.json`, `.secret*` — because a `cp` or `mv` that is auto-allowed on both ends
|
||||
# would rename one out of those globs and hand back through `Read` exactly what they deny.
|
||||
path_class() {
|
||||
local canon="$1" d root=""
|
||||
case "$canon" in
|
||||
*"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;;
|
||||
*"/.env" | *"/.env."*) return 1 ;;
|
||||
*"/secrets" | *"/secrets/"*) return 1 ;;
|
||||
*.pem | *.key | *"/credentials.json") return 1 ;;
|
||||
*"/.secret"* | *.secret | *.secrets) return 1 ;;
|
||||
esac
|
||||
case "$canon" in /tmp/?*) printf 'tmp'; return 0 ;; esac
|
||||
[ -n "${HOME:-}" ] || return 1
|
||||
case "$canon" in "$HOME"/?*) ;; *) return 1 ;; esac
|
||||
d="$canon"
|
||||
while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do
|
||||
[ -e "$d/.git" ] && { root="$d"; break; }
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
[ -n "$root" ] || return 1 # not inside a git working tree under $HOME
|
||||
if [ "$canon" = "$root" ]; then
|
||||
[ -f "$root/.git" ] || return 1
|
||||
fi
|
||||
printf 'repo:%s' "$root"
|
||||
}
|
||||
|
||||
# 0 iff <verb> ($1) runs as a command word anywhere in <command> ($2). Mirrors how a Bash
|
||||
# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt:
|
||||
# a guard consults this before it starts proving segments, and every bail-out it then takes
|
||||
# is a prompt for exactly the commands a rule would have caught.
|
||||
runs_verb() {
|
||||
local verb="$1" seg
|
||||
split_segments "$2"
|
||||
for seg in "${SEGMENTS[@]}"; do
|
||||
segment_runs_verb "$verb" "$seg" && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Emit a PreToolUse decision and exit. `ask` is the ordinary permission prompt.
|
||||
decide() {
|
||||
jq -nc --arg d "$1" --arg r "$2" \
|
||||
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:$d,permissionDecisionReason:$r}}'
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env bash
|
||||
# Decision table for the two scratch-dir PreToolUse guards. Run: bash .claude/hooks/test-hooks.sh
|
||||
#
|
||||
# What this pins is the `ask` column: a matcher change that turns one into a no-decision drops
|
||||
# that command's only prompt (see lib-guarded-verb.sh). The wrapper, nested-command and quoted
|
||||
# rows are the ones that catch it.
|
||||
#
|
||||
# The `allow` column carries its own weight, because a decision covers the whole command line:
|
||||
# `allow` may only appear where every segment was proved here, and a line that also runs
|
||||
# something unexamined has to come out `none` so the normal permission flow still sees it.
|
||||
set -uo pipefail
|
||||
H="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
|
||||
CWD="$(git -C "$H" rev-parse --show-toplevel)"
|
||||
OUT="$HOME/not-a-git-tree" # never written to; only the guards' path checks look at it
|
||||
fails=0
|
||||
|
||||
# A tree's own root is auto-allowable only when it is a LINKED worktree, whose `.git` is a
|
||||
# pointer file so the history lives in the main repo and survives; a primary checkout's `.git`
|
||||
# is the history itself. The suite runs from either kind, so the rows that name the root follow
|
||||
# the one it is run in — which is also what pins both halves of that rule.
|
||||
if [ -f "$CWD/.git" ]; then
|
||||
ROOT_SOLO=allow ROOT_CHAINED=none # linked worktree
|
||||
else
|
||||
ROOT_SOLO=ask ROOT_CHAINED=ask # primary checkout
|
||||
fi
|
||||
|
||||
run() { # run <hook> <allow|ask|none> <command>
|
||||
local hook="$1" want="$2" cmd="$3" out got
|
||||
out=$(jq -nc --arg c "$cmd" --arg w "$CWD" \
|
||||
'{tool_name:"Bash",tool_input:{command:$c},cwd:$w}' | "$H/$hook" 2>&1)
|
||||
if [ -z "$out" ]; then
|
||||
got=none
|
||||
else
|
||||
got=$(printf '%s' "$out" | jq -r '.hookSpecificOutput.permissionDecision // "PARSE-ERROR"' 2>/dev/null || echo PARSE-ERROR)
|
||||
fi
|
||||
local shown="${cmd//$'\n'/ ⏎ }"
|
||||
if [ "$got" = "$want" ]; then
|
||||
printf ' ok %-5s %s\n' "$got" "$shown"
|
||||
else
|
||||
printf 'FAIL want=%-5s got=%-5s %s\n %s\n' "$want" "$got" "$shown" "$out"
|
||||
fails=$((fails + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== guard-rm-outside-tmp.sh =="
|
||||
G=guard-rm-outside-tmp.sh
|
||||
run $G allow "rm -rf /tmp/scratch/x"
|
||||
run $G allow "rm -rf /tmp/scratch/*"
|
||||
run $G allow "rm -rf $CWD/frontend/scratch"
|
||||
run $G ask "rm -rf /tmp"
|
||||
run $G ask "rm -rf $OUT"
|
||||
run $G ask "rm -rf $CWD/.git"
|
||||
run $G ask "rm -rf $CWD/.claude/hooks" # the guards may not delete themselves
|
||||
run $G ask "rm $CWD/.claude/settings.json"
|
||||
run $G ask "rm $CWD/.claude/settings.local.json"
|
||||
run $G ask "rm -rf $CWD/backend/.env"
|
||||
run $G ask "rm -rf $CWD/.env.local"
|
||||
run $G $ROOT_SOLO "rm -rf $CWD"
|
||||
run $G ask "rm -rf $CWD/*"
|
||||
run $G ask "rm -rf /etc/passwd"
|
||||
run $G ask 'rm -rf "$HOME/x"'
|
||||
run $G ask "rm -rf /tmp/../$OUT"
|
||||
run $G none "ls /tmp && rm -rf /tmp/x" # proved delete, unexamined neighbour
|
||||
run $G ask 'echo $(rm -rf /etc)'
|
||||
run $G ask 'echo `rm -rf /etc`'
|
||||
run $G ask "{ rm -rf /etc; }"
|
||||
run $G allow "{ rm -rf /tmp/scratch/x; }" # the keyword drops, the delete still proves
|
||||
run $G ask "find . -name x | xargs rm"
|
||||
run $G ask "timeout 5 rm -rf /tmp/x"
|
||||
run $G ask "stdbuf -o L rm -rf /etc"
|
||||
run $G ask "FOO=bar rm -rf /tmp/x"
|
||||
run $G ask "/bin/rm -rf /tmp/x"
|
||||
run $G ask "'rm' -rf /etc"
|
||||
run $G ask 'r\m -rf /etc'
|
||||
run $G ask "! rm -rf /etc"
|
||||
run $G ask "if true; then rm -rf /etc; fi"
|
||||
run $G ask ">/dev/null rm -rf $OUT"
|
||||
# Data that merely mentions a verb is not a command. Both of these prompted in the field.
|
||||
run $G none "$(printf 'gh pr create --body "$(cat <<%sEOF%s\ndrop `rm` and `mv` from the ask list\nrm is now guarded here\nEOF\n)"' "'" "'")"
|
||||
run $G none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
|
||||
# A wrapper's own flags and assignments are unbounded, so they may not be charged against the
|
||||
# scan that looks past it — these run rm and must prompt.
|
||||
run $G ask "env -i HOME=/tmp PATH=/usr/bin LANG=C USER=root SHELL=/bin/sh rm -rf /etc"
|
||||
run $G ask "sudo -E -H -u root FOO=1 BAR=2 rm -rf $OUT"
|
||||
run $G ask "xargs -a f -d d -E e -I {} -L 1 -n 1 rm /etc"
|
||||
run $G ask "env -u A -u B -u C -u D -u E -u F -u G rm -rf /etc"
|
||||
run $G ask "sudo -u 'root' rm -rf /etc"
|
||||
run $G ask "$(printf 'echo hi # cat <<EOF\nrm -rf /etc\nEOF')"
|
||||
# A `<<` inside a quoted string or a comment opens no heredoc, so the command under it is real.
|
||||
run $G ask "$(printf 'echo "cat <<EOF"\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'echo "cat <<EOF and more"\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'echo "cat <<EOF "\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf '# usage: cat <<EOF\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'echo "cat <<EOF > f"\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'echo "cat <<true > /tmp/a"\nrm -rf /etc\ntrue')"
|
||||
run $G ask "$(printf "echo 'cat <<EOF | tee'\nrm -rf /etc\nEOF")"
|
||||
# A body fed to a shell is executed, so it is commands and not data.
|
||||
run $G ask "$(printf 'bash <<EOF\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'cat <<EOF | bash\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'ssh host <<EOF\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'bash<<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf '/bin/sh <<EOF\nrm -rf /etc\nEOF')"
|
||||
run $G ask "$(printf 'cat <<%sEOF%s|bash\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf 'out=$(bash <<%sEOF%s\nrm -rf /etc\nEOF\n)' "'" "'")"
|
||||
run $G ask "$(printf 'bash \\\n <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf 'ash <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf 'busybox sh <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf 'sudo -s <<%sEOF%s\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G ask "$(printf '(bash <<%sEOF%s)\nrm -rf /etc\nEOF' "'" "'")"
|
||||
|
||||
# A redirect or pipe after the delimiter is still a real heredoc.
|
||||
run $G none "$(printf 'cat <<%sEOF%s > /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
|
||||
run $G none "$(printf 'cat <<%sEOF%s 2>&1 | tee /tmp/a\nrm -rf /etc\nEOF' "'" "'")"
|
||||
# An unquoted body is expanded before its consumer sees it, so it is code.
|
||||
run $G ask "$(printf 'cat <<EOF > /tmp/a\n$(rm -rf /etc)\nEOF')"
|
||||
run $G ask "$(printf 'cat <<EOF > /tmp/a\nrm -rf /etc\nEOF')"
|
||||
# ... but a real command after a heredoc still is one.
|
||||
run $G ask "$(printf 'cat <<EOF > /tmp/s.sh\nhello\nEOF\nrm -rf %s' "$OUT")"
|
||||
run $G ask "$(printf 'echo "a << b"\nrm -rf %s' "$OUT")"
|
||||
run $G none "git rm frontend/foo.ts"
|
||||
run $G none 'echo $(ls /tmp)'
|
||||
run $G none 'grep -rn "rm" backend/'
|
||||
run $G none "cargo build --release"
|
||||
|
||||
# Chaining and line breaks are not themselves a reason to prompt: each segment is proved on its
|
||||
# own operands, and a `cd` moves where a relative one points.
|
||||
run $G allow "rm -f /tmp/a; rm -rf /tmp/b"
|
||||
run $G allow "$(printf 'rm -f /tmp/a\nrm -rf %s/frontend/scratch' "$CWD")"
|
||||
run $G allow "cd /tmp/scratch && rm -rf sub"
|
||||
run $G none "mkdir -p /tmp/x && rm -rf /tmp/x"
|
||||
run $G ask "$(printf 'ls /tmp\nrm -rf /etc')"
|
||||
# A `cd` this guard can resolve is where the relative operand lands; one it cannot leaves the
|
||||
# working directory unknown, and an unknown one proves nothing.
|
||||
run $G ask "cd /etc && rm -rf foo"
|
||||
run $G ask 'cd "$D" && rm -rf foo'
|
||||
run $G ask "cd $CWD && rm -rf .git"
|
||||
run $G ask "cd /etc && cd /tmp/scratch && rm -rf sub" # a cd out is not walked back
|
||||
# A `cd` can fail at runtime, and `;` runs the delete from where the command started, so a
|
||||
# relative operand is proved from both directories.
|
||||
run $G ask "cd /tmp/does-not-exist; rm -rf .git"
|
||||
run $G ask "cd /tmp/does-not-exist; rm -rf backend/.env"
|
||||
run $G ask "cd /tmp/a && cd /tmp/b && rm -rf sub"
|
||||
run $G ask "rm -rf /tmp/clone/.git" # history is never in a class
|
||||
run $G ask "rm -rf /tmp/scratch/id_rsa.key"
|
||||
run $G none "cd /tmp >$OUT; rm -f /tmp/a"
|
||||
# A substitution is concatenated into its word, so splitting on it would prove only the literal
|
||||
# half; a relative `cd` is not $cwd/$t either, since the shell searches $CDPATH first.
|
||||
run $G ask 'rm -rf /tmp/a/`printf ../../etc`'
|
||||
run $G ask 'rm -rf /tmp/a/$(printf ../../etc)'
|
||||
run $G ask "cd ssh && rm -rf moduli"
|
||||
|
||||
echo
|
||||
echo "== allow-fileops-in-tmp.sh =="
|
||||
A=allow-fileops-in-tmp.sh
|
||||
run $A allow "mv /tmp/a /tmp/b"
|
||||
run $A allow "chmod 755 /tmp/a"
|
||||
run $A allow "cp -r /tmp/a /tmp/b"
|
||||
run $A allow "tar -xzf /tmp/a.tar.gz -C /tmp/out"
|
||||
run $A ask "mv /tmp/a $OUT"
|
||||
run $A ask "mv $CWD/AGENTS.md /tmp/a"
|
||||
run $A $ROOT_SOLO "chmod -R 777 $CWD"
|
||||
run $A none "ls && mv /tmp/a /tmp/b" # proved move, unexamined neighbour
|
||||
run $A ask 'echo $(mv /tmp/a /etc)'
|
||||
run $A ask "timeout --signal KILL 5 mv /tmp/a /etc"
|
||||
run $A ask "time -f FORMAT chmod 777 $OUT"
|
||||
run $A ask "'mv' /tmp/a /etc"
|
||||
run $A ask 'ch\mod 777 /etc'
|
||||
run $A none "$(printf 'claude -p "run these in order:\n1: rm -rf /tmp/a\n2: mv /tmp/b /tmp/c"')"
|
||||
run $A ask "env -i A=1 B=2 C=3 D=4 E=5 F=6 mv /tmp/a /etc"
|
||||
run $A none "cp $CWD/AGENTS.md /tmp/a"
|
||||
run $A none "tar -xzf /tmp/a.tar.gz -C $OUT"
|
||||
run $A none "cargo build"
|
||||
|
||||
run $A none "mkdir -p /tmp/x; mv /tmp/a /tmp/x; chmod 755 /tmp/x" # one write per line
|
||||
run $A none "$(printf 'mv /tmp/a /tmp/b\nchmod 755 /tmp/b')"
|
||||
run $A ask "ls && mv /tmp/a /etc"
|
||||
run $A $ROOT_CHAINED "$(printf 'mkdir -p /tmp/x\nchmod -R 777 %s' "$CWD")"
|
||||
run $A allow "cd /tmp/x && tar -xzf /tmp/a.tar.gz -C /tmp/out"
|
||||
# The checkout is a root of its own, so an in-repo move or chmod is as auto-allowable as the
|
||||
# in-repo delete already was — but one operation may not straddle it and /tmp.
|
||||
run $A allow "chmod +x scripts/worktree-env"
|
||||
run $A allow "mv backend/.sqlx backend/.sqlx.bad"
|
||||
run $A allow "mv $CWD/frontend/a.ts $CWD/frontend/b.ts"
|
||||
run $A ask "mv /tmp/a $CWD/frontend/a.ts"
|
||||
run $A ask "chmod -R 777 $CWD/.git"
|
||||
run $A ask "mv $CWD/backend/.env $CWD/backend/.env.bak"
|
||||
run $A ask "mv $CWD/AGENTS.md $OUT"
|
||||
run $A ask "cd /etc && mv a b"
|
||||
# An auto-allowed rename may not carry a path out of the `Read` deny globs.
|
||||
run $A ask "mv backend/server.pem backend/server.txt"
|
||||
run $A none "cp backend/secrets/token frontend/token.txt" # cp has no prompt of its own,
|
||||
# so what matters is it is not allowed
|
||||
run $A ask "mv $CWD/backend/credentials.json /tmp/x"
|
||||
run $A ask "cd /tmp/does-not-exist; mv .claude/settings.json settings.bak"
|
||||
# A segment this hook cannot read whole may carry a redirect, and an earlier write can change
|
||||
# what a later operand resolves to — neither may ride along on an allow.
|
||||
run $A none "cd /tmp >$OUT; mv /tmp/a /tmp/b"
|
||||
run $A none "cp -r /tmp/tree /tmp/live; cp /tmp/payload /tmp/live/link"
|
||||
run $A ask 'mv /tmp/a/`printf ../../etc/x` /tmp/b'
|
||||
# A sibling checkout is a different root: its files are outside what the Read tool is confined
|
||||
# to, and copying them in would hand back what that confinement withholds.
|
||||
EE="$(dirname "$CWD")/windmill-ee-private" # a sibling checkout; absent elsewhere, still not a root
|
||||
run $A ask "mv $EE/backend/x.rs $CWD/backend/x.rs"
|
||||
run $A none "cp $EE/README.md $CWD/README.copy"
|
||||
# Directory form writes a path the command does not name — DEST/basename(SRC) — and `cp`
|
||||
# follows that child when it is a symlink, as every `*_ee.rs` in this checkout is.
|
||||
run $A ask "mv frontend/apps_ee.rs backend/windmill-api/src"
|
||||
run $A none "cp frontend/apps_ee.rs backend/windmill-api/src"
|
||||
run $A none "cp frontend/a.ts backend"
|
||||
run $A ask "mv /tmp/a $CWD/backend"
|
||||
# ... and a `cd` that fails at runtime may not hide that form: the destination is a directory
|
||||
# in the directory the command actually ran in, whichever of the two that turns out to be.
|
||||
run $A none "cd $CWD/AGENTS.md; cp frontend/apps_ee.rs backend/windmill-api/src"
|
||||
run $A ask "cd $CWD/AGENTS.md; mv frontend/apps_ee.rs backend/windmill-api/src"
|
||||
run $A none "cd /tmp/x && tar -xzf /tmp/a.tar.gz" # no -C, and the cwd is now two candidates
|
||||
run $A allow "cp frontend/a.ts backend/a.ts" # ... naming the destination proves fine
|
||||
|
||||
echo
|
||||
[ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILURES"; exit 1; }
|
||||
@@ -73,10 +73,7 @@
|
||||
"Edit(**/.env.*)"
|
||||
],
|
||||
"ask": [
|
||||
"Bash(rm:*)",
|
||||
"Bash(rmdir:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(chown:*)",
|
||||
"Bash(truncate:*)",
|
||||
"Bash(shred:*)",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!--
|
||||
We are not seeking outside contribution at this time. Small, trivially-verified PRs that fix a
|
||||
problem are still welcome; low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines
|
||||
will be closed with a reference to CONTRIBUTING.md.
|
||||
|
||||
For a bigger idea, please open a feature request instead:
|
||||
https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md
|
||||
|
||||
Read https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md before submitting.
|
||||
-->
|
||||
|
||||
## What does this PR do?
|
||||
|
||||
## Related issue
|
||||
@@ -22,6 +22,7 @@ on:
|
||||
- "frontend/src/lib/userDraft.svelte.ts"
|
||||
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
|
||||
- "frontend/src/lib/infer.ts"
|
||||
- "frontend/src/lib/components/sessions/**"
|
||||
- ".github/workflows/ai-evals-test.yml"
|
||||
pull_request:
|
||||
types: [opened, reopened, ready_for_review]
|
||||
@@ -35,6 +36,7 @@ on:
|
||||
- "frontend/src/lib/userDraft.svelte.ts"
|
||||
- "frontend/src/lib/userDraftDbSyncer.svelte.ts"
|
||||
- "frontend/src/lib/infer.ts"
|
||||
- "frontend/src/lib/components/sessions/**"
|
||||
- ".github/workflows/ai-evals-test.yml"
|
||||
|
||||
concurrency:
|
||||
@@ -124,6 +126,11 @@ jobs:
|
||||
bun install
|
||||
bun test adapters/
|
||||
|
||||
# Harness code that reaches into the frontend module graph; bun cannot load it.
|
||||
- name: Run harness unit tests (frontend graph)
|
||||
working-directory: ./ai_evals
|
||||
run: bun run test:frontend-graph
|
||||
|
||||
- name: Run global AI evals
|
||||
timeout-minutes: 20
|
||||
working-directory: ./ai_evals
|
||||
|
||||
@@ -9,6 +9,13 @@ on:
|
||||
- "windmill-yaml-validator/**"
|
||||
- "backend/migrations/**"
|
||||
- ".github/workflows/cli-tests.yml"
|
||||
# The bundles cli/ vendors from the frontend: their drift guards live in
|
||||
# cli/test but the edits that break them land here. The policy bundle
|
||||
# inlines its imports too, so those sources belong in the filter.
|
||||
- "frontend/src/lib/components/raw_apps/**"
|
||||
- "frontend/src/lib/components/recording/**"
|
||||
- "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
|
||||
- "frontend/src/lib/components/apps/inputType.ts"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
@@ -16,6 +23,13 @@ on:
|
||||
- "windmill-yaml-validator/**"
|
||||
- "backend/migrations/**"
|
||||
- ".github/workflows/cli-tests.yml"
|
||||
# The bundles cli/ vendors from the frontend: their drift guards live in
|
||||
# cli/test but the edits that break them land here. The policy bundle
|
||||
# inlines its imports too, so those sources belong in the filter.
|
||||
- "frontend/src/lib/components/raw_apps/**"
|
||||
- "frontend/src/lib/components/recording/**"
|
||||
- "frontend/src/lib/components/apps/editor/commonAppUtils.ts"
|
||||
- "frontend/src/lib/components/apps/inputType.ts"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -167,8 +167,14 @@ jobs:
|
||||
env:
|
||||
EXTRA_PROMPT: ${{ inputs.extra_prompt }}
|
||||
run: |
|
||||
# prior-comments.md is PR comment text verbatim, and commenting needs no write access.
|
||||
# With a fixed delimiter, a comment containing a bare `EOF` line closes the block early:
|
||||
# the step dies, and whatever follows in that comment is read as further environment
|
||||
# assignments for the rest of this job, which holds the review tokens. Hence a random
|
||||
# delimiter, per GitHub's guidance for untrusted multiline values.
|
||||
delimiter="REVIEW_PROMPT_EOF_$(openssl rand -hex 16)"
|
||||
{
|
||||
echo 'REVIEW_PROMPT<<EOF'
|
||||
echo "REVIEW_PROMPT<<$delimiter"
|
||||
cat REVIEW.md
|
||||
echo ''
|
||||
cat .claude/review-prompt.md
|
||||
@@ -182,7 +188,7 @@ jobs:
|
||||
echo ''
|
||||
cat prior-comments.md
|
||||
fi
|
||||
echo 'EOF'
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Automatic PR Review
|
||||
|
||||
@@ -25,16 +25,20 @@ jobs:
|
||||
REMAINDER_FIRST_LINE=${FIRST_LINE#"$FIRST_WORD"}
|
||||
REMAINDER_FIRST_LINE=${REMAINDER_FIRST_LINE# }
|
||||
REST=$(printf '%s' "$BODY" | tail -n +2)
|
||||
# The value is the comment body, which anyone can write. A fixed delimiter lets a
|
||||
# comment close the block early and have the rest of itself read as further step
|
||||
# outputs, so the delimiter has to be unguessable.
|
||||
delimiter="EXTRA_EOF_$(openssl rand -hex 16)"
|
||||
{
|
||||
echo "command=$COMMAND"
|
||||
echo 'extra_prompt<<EXTRA_EOF'
|
||||
echo "extra_prompt<<$delimiter"
|
||||
if [ -n "$REMAINDER_FIRST_LINE" ]; then
|
||||
printf '%s\n' "$REMAINDER_FIRST_LINE"
|
||||
fi
|
||||
if [ -n "$REST" ]; then
|
||||
printf '%s\n' "$REST"
|
||||
fi
|
||||
echo 'EXTRA_EOF'
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -21,9 +21,15 @@ jobs:
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_PAT }}
|
||||
with:
|
||||
path-to-signatures: "signatures/cla.json"
|
||||
path-to-document: "https://github.com/windmill-labs/windmill/blob/master/CLA.md"
|
||||
path-to-document: "https://github.com/windmill-labs/windmill/blob/main/CLA.md"
|
||||
branch: "signatures"
|
||||
allowlist: rubenfiszel,bot*
|
||||
custom-notsigned-prcomment: |
|
||||
Thank you for taking the time to open this PR.
|
||||
|
||||
Please note that **we are not seeking outside contribution at this time**. Small, trivially-verified PRs that fix a problem are still accepted, but low-value PRs (e.g. typo fixes) and PRs longer than a dozen or so lines will be closed. If you have a bigger idea, please open a [feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md) instead. See [CONTRIBUTING.md](https://github.com/windmill-labs/windmill/blob/main/CONTRIBUTING.md) for the full policy.
|
||||
|
||||
If your PR falls within that scope, we ask that you sign our [Contributor License Agreement](https://github.com/windmill-labs/windmill/blob/main/CLA.md) before we can accept it. You can sign the CLA by just posting a Pull Request Comment same as the below format.
|
||||
|
||||
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
|
||||
#remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "1.789.0"
|
||||
".": "1.792.2"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ Open-source platform for internal tools, workflows, API integrations, background
|
||||
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
|
||||
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
|
||||
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
|
||||
- **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server.
|
||||
|
||||
## Dev Environment
|
||||
|
||||
@@ -146,10 +147,19 @@ $NAV --root backend callees "X" # what does X call?
|
||||
- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics
|
||||
- **Scratch stays outside the checkout.** Temp scripts, data dumps, cache backups and
|
||||
screenshots go in the session scratch directory or `/tmp`, so nothing temporary can end up
|
||||
committed. Write `rm`/`mv`/`cp` as one plain unchained command: a PreToolUse hook
|
||||
auto-allows those when every operand is under `/tmp` or inside this checkout, but it defers
|
||||
on `&&`, `;`, redirects, quotes and `$VAR` — that deferral, not the delete itself, is what
|
||||
turns a routine cleanup into a permission prompt.
|
||||
committed. Write the paths in `rm`/`mv`/`cp` out literally: a PreToolUse hook proves each
|
||||
operand, and auto-allows deletes, moves, copies and mode changes under `/tmp` or inside a git
|
||||
checkout under `$HOME`, as long as one operation stays within a single root — a sibling
|
||||
checkout is a root of its own (`tar` and `unzip` stay `/tmp`-only). Chain deletes freely, each
|
||||
proved on its own operands, but keep writes to one per line, name the destination rather than
|
||||
a directory to drop it in, and put anything else on its own line: a command the hook does not
|
||||
prove drops the whole line back to the normal permission flow. A
|
||||
quoted or `$VAR` operand, a `~`, a redirect, a `$(…)`, a relative `cd`, or a wrapper like
|
||||
`xargs rm` cannot be proved, and that deferral is what turns a cleanup into a prompt.
|
||||
- **Change files with Edit/Write, not the shell.** `sed -i`, `cat > file <<'EOF'` and inline
|
||||
`python3 - <<'PY'` scripts put an edit through the PreToolUse guards and the permission
|
||||
classifier, which match `Bash` and nothing else, so a routine edit arrives as a prompt. Bash
|
||||
stays right for running things — tests, builds, git, one-off queries.
|
||||
- Search for existing code to reuse before writing new code
|
||||
- Follow established patterns in the codebase
|
||||
- Keep changes focused — don't refactor beyond what's asked
|
||||
|
||||
@@ -1,5 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## [1.792.2](https://github.com/windmill-labs/windmill/compare/v1.792.1...v1.792.2) (2026-08-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* check direct-deployment lock and superadmin in the deploy preflight ([#10748](https://github.com/windmill-labs/windmill/issues/10748)) ([ef8a8e8](https://github.com/windmill-labs/windmill/commit/ef8a8e821ca3a5f4a308c49226292565680bf90c))
|
||||
* make the listScripts parent_hash filter valid SQL ([#10752](https://github.com/windmill-labs/windmill/issues/10752)) ([f34b7fb](https://github.com/windmill-labs/windmill/commit/f34b7fbcfa104bdc00abbfc59ab4a3dd8cafe0e0))
|
||||
* **security:** validate ansible git repository URLs before invoking git ([#10759](https://github.com/windmill-labs/windmill/issues/10759)) ([fa7fbd3](https://github.com/windmill-labs/windmill/commit/fa7fbd348d4b184ea95cfc953ded7c72b6f04e5e))
|
||||
|
||||
## [1.792.1](https://github.com/windmill-labs/windmill/compare/v1.792.0...v1.792.1) (2026-08-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* route legacy AI entry points to sessions instead of the unmounted chat ([#10705](https://github.com/windmill-labs/windmill/issues/10705)) ([494e6f1](https://github.com/windmill-labs/windmill/commit/494e6f146e6a22bd498db58fa10c7866cd56dc4d))
|
||||
|
||||
## [1.792.0](https://github.com/windmill-labs/windmill/compare/v1.791.0...v1.792.0) (2026-08-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **frontend:** record the outcome of every AI chat tool call ([#10746](https://github.com/windmill-labs/windmill/issues/10746)) ([7b17e35](https://github.com/windmill-labs/windmill/commit/7b17e358b35bf4ef8c213ea13252a456e87acb32))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** document cache_ignore_s3_path on the Script read schema ([#10742](https://github.com/windmill-labs/windmill/issues/10742)) ([6783a39](https://github.com/windmill-labs/windmill/commit/6783a396b144948fa60324eae888bc4a83917bc8))
|
||||
* audit the icon library against brand guidelines ([#10722](https://github.com/windmill-labs/windmill/issues/10722)) ([6749015](https://github.com/windmill-labs/windmill/commit/6749015fbf7afe0c6dcd53b1933b0152915afd32))
|
||||
* **cli:** keep script settings on push and repair the up-to-date check ([#10741](https://github.com/windmill-labs/windmill/issues/10741)) ([ef4dc46](https://github.com/windmill-labs/windmill/commit/ef4dc46d4bfd00e583a39e5c053c0022f1ad3abd))
|
||||
* show runtime-detected assets in a run's Assets tab ([#10738](https://github.com/windmill-labs/windmill/issues/10738)) ([1fa3bf3](https://github.com/windmill-labs/windmill/commit/1fa3bf3b291c32ffd62f77df59e24993afb7c78a))
|
||||
|
||||
## [1.791.0](https://github.com/windmill-labs/windmill/compare/v1.790.1...v1.791.0) (2026-08-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add empty state cards to list pages ([#10726](https://github.com/windmill-labs/windmill/issues/10726)) ([66bffaa](https://github.com/windmill-labs/windmill/commit/66bffaa60d48f992e56e459efb813b24e3942610))
|
||||
* **copilot:** let plan mode draw, but never write the plan ([#10725](https://github.com/windmill-labs/windmill/issues/10725)) ([fd9295a](https://github.com/windmill-labs/windmill/commit/fd9295a58e6868ccc6f371e35b875ae27196e99a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* compile resource types with no properties instead of throwing ([#10730](https://github.com/windmill-labs/windmill/issues/10730)) ([b17fdab](https://github.com/windmill-labs/windmill/commit/b17fdab8ff96aa7bbfc8b14389294bda1a9e0a07))
|
||||
* derive a raw app's policy on deploy, and default an omitted execution_mode ([#10733](https://github.com/windmill-labs/windmill/issues/10733)) ([343ce6e](https://github.com/windmill-labs/windmill/commit/343ce6e143343e65613d52d0f12c5264b4ab4c3a))
|
||||
* include delete_after_secs in script deploy payload ([#10731](https://github.com/windmill-labs/windmill/issues/10731)) ([05eba6c](https://github.com/windmill-labs/windmill/commit/05eba6c9ab078cdedc87f197549dbdbc4b360fe3))
|
||||
* support [@typechecked](https://github.com/typechecked) decorator in Python relative imports ([#8495](https://github.com/windmill-labs/windmill/issues/8495)) ([ab3c020](https://github.com/windmill-labs/windmill/commit/ab3c0206d7e9b32676d99ed0cd8c9d8939122584))
|
||||
* type s3-streamed columns that are all-null in the inference sample ([#10728](https://github.com/windmill-labs/windmill/issues/10728)) ([6b5b9f7](https://github.com/windmill-labs/windmill/commit/6b5b9f72d4f9ce86b21d8d21ae342b6c8dc14b93))
|
||||
|
||||
## [1.790.1](https://github.com/windmill-labs/windmill/compare/v1.790.0...v1.790.1) (2026-08-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fall back to polling when a proxy mutes the job SSE stream ([#10716](https://github.com/windmill-labs/windmill/issues/10716)) ([64d78b4](https://github.com/windmill-labs/windmill/commit/64d78b4db1d7d939c598c86d1998218d52fbcc21))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cap resource content sent to the search modal ([#10714](https://github.com/windmill-labs/windmill/issues/10714)) ([529e960](https://github.com/windmill-labs/windmill/commit/529e9606297ee0b41456a66222f31409d7bc7669))
|
||||
* unblock workers before the API router is built ([#10711](https://github.com/windmill-labs/windmill/issues/10711)) ([0258f3f](https://github.com/windmill-labs/windmill/commit/0258f3f81b96bb8d4e343ba8aeba614f9c836579))
|
||||
|
||||
## [1.790.0](https://github.com/windmill-labs/windmill/compare/v1.789.0...v1.790.0) (2026-08-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add trigger_history table with source tracking ([#10696](https://github.com/windmill-labs/windmill/issues/10696)) ([633d7bc](https://github.com/windmill-labs/windmill/commit/633d7bcb2ea034c39b72f7a8f5109b4ccd71b0be))
|
||||
* advertise the pinned artifact version in get_preview_status ([#10691](https://github.com/windmill-labs/windmill/issues/10691)) ([850b028](https://github.com/windmill-labs/windmill/commit/850b028778afe0cda1dc357c9e647d066339f3ce))
|
||||
* **ai-sessions:** add plan mode ([#10057](https://github.com/windmill-labs/windmill/issues/10057)) ([caa1898](https://github.com/windmill-labs/windmill/commit/caa189868c6ec9ebc2b6311e07308a83fa25ad8d))
|
||||
* let the global AI chat call connected MCP servers as the user ([#10656](https://github.com/windmill-labs/windmill/issues/10656)) ([3f07a1a](https://github.com/windmill-labs/windmill/commit/3f07a1a803a3f8a176de754188f641bdfcaa6cec))
|
||||
* stream audit logs in batches when a page is slow to load ([#10695](https://github.com/windmill-labs/windmill/issues/10695)) ([9334727](https://github.com/windmill-labs/windmill/commit/9334727d99eac251b0a995916c7ea00bd9596cef))
|
||||
* **telemetry:** extend feature-usage tracking beyond AI features ([#10681](https://github.com/windmill-labs/windmill/issues/10681)) ([53eb946](https://github.com/windmill-labs/windmill/commit/53eb94659bd27e75ed4acf4ce414046ac8df4cc8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agents:** let the scratch-dir hooks own their permission prompt ([#10702](https://github.com/windmill-labs/windmill/issues/10702)) ([0a40b38](https://github.com/windmill-labs/windmill/commit/0a40b3806fc08c6d7b2f9fd9b7ade07fdf1841f1))
|
||||
* **agents:** stop the scratch-dir guards prompting on quoted text ([#10703](https://github.com/windmill-labs/windmill/issues/10703)) ([e6e2e53](https://github.com/windmill-labs/windmill/commit/e6e2e53e97bebd407d72819d6163c04b6fc0b6b0))
|
||||
* **ci:** use random delimiters for untrusted multiline workflow values ([#10706](https://github.com/windmill-labs/windmill/issues/10706)) ([0fc74de](https://github.com/windmill-labs/windmill/commit/0fc74dec5f9d9d6594f1bc4a85b895ee8c3bf17b))
|
||||
* confine jobs:run tokens to the jobs of the runnables they may start ([#10635](https://github.com/windmill-labs/windmill/issues/10635)) ([ee53327](https://github.com/windmill-labs/windmill/commit/ee533273dd2fa0dc70e45b9750f3556002b150b7))
|
||||
* drop sampling params on Claude models that reject them ([#10708](https://github.com/windmill-labs/windmill/issues/10708)) ([3468cb6](https://github.com/windmill-labs/windmill/commit/3468cb68b12c27f7f133e350b433fd9379fc8b07))
|
||||
* **groups:** replace instance-group delta-patching with a state-based reconciler ([#10686](https://github.com/windmill-labs/windmill/issues/10686)) ([b551033](https://github.com/windmill-labs/windmill/commit/b5510333eac99f575aa2251398ca58626e419968))
|
||||
* keep a resource's linked secret reference in sync while renaming ([#10693](https://github.com/windmill-labs/windmill/issues/10693)) ([60c5ad2](https://github.com/windmill-labs/windmill/commit/60c5ad252afe23642410743632be2f6eaf2fbffd))
|
||||
* keep non traffic-serving processes out of coordinated restarts ([#10694](https://github.com/windmill-labs/windmill/issues/10694)) ([6d03784](https://github.com/windmill-labs/windmill/commit/6d03784d4b15535666bd4afdc5bbde5af016e078))
|
||||
* recover from a refused mcp read assertion, drop stale discovery ([#10710](https://github.com/windmill-labs/windmill/issues/10710)) ([effdcd9](https://github.com/windmill-labs/windmill/commit/effdcd99155a9235856b245b7f49a37e0632db08))
|
||||
* refresh AI provider model defaults and capability metadata ([#10690](https://github.com/windmill-labs/windmill/issues/10690)) ([68fc782](https://github.com/windmill-labs/windmill/commit/68fc7825bb5cd04347debb1a30af227614b9d9f5))
|
||||
* send sage_intacct oauth client credentials in the request body ([#10685](https://github.com/windmill-labs/windmill/issues/10685)) ([bd5b3ea](https://github.com/windmill-labs/windmill/commit/bd5b3ea779fa6351e937fc3639f0bb985ffc1ce9))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* back off the interactive worker shell under EXIT_AFTER_N_JOBS ([#10700](https://github.com/windmill-labs/windmill/issues/10700)) ([578d5e9](https://github.com/windmill-labs/windmill/commit/578d5e9a7d1016deaa81e5bf314029c5d7de9589))
|
||||
* cache resolved python interpreter path across worker restarts ([#10701](https://github.com/windmill-labs/windmill/issues/10701)) ([878b8ef](https://github.com/windmill-labs/windmill/commit/878b8ef4c47f650686d4a42fc9763d57cc1c62cf))
|
||||
* declare a settings pass instead of reading one setting at a time ([#10698](https://github.com/windmill-labs/windmill/issues/10698)) ([30f5d2e](https://github.com/windmill-labs/windmill/commit/30f5d2e7660ad5bfa335d76a69bd5c3ad8e70c77))
|
||||
* resolve the worker external IP in the background ([#10697](https://github.com/windmill-labs/windmill/issues/10697)) ([22eadab](https://github.com/windmill-labs/windmill/commit/22eadab67d52fe4cb6bf1e73d161a6a439770295))
|
||||
|
||||
## [1.789.0](https://github.com/windmill-labs/windmill/compare/v1.788.0...v1.789.0) (2026-08-13)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Contributing to Windmill
|
||||
|
||||
At this time, we are not seeking outside contribution.
|
||||
|
||||
AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it,
|
||||
making sure quality stays high, and keeping the product coherent. In that light, unfortunately,
|
||||
external code contributions are "donating" the easy part of the job, while creating more of the
|
||||
hard work.
|
||||
|
||||
With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However,
|
||||
we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a
|
||||
dozen or so lines. Such PRs will be closed with a reference to this guideline.
|
||||
|
||||
If you have a big idea you'd like us to consider, feel free to open a
|
||||
[feature request](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md)
|
||||
about it.
|
||||
|
||||
This policy may change in the future as the project matures. Until then, thank you for your
|
||||
understanding.
|
||||
|
||||
## What is still very welcome
|
||||
|
||||
- [Bug reports](https://github.com/windmill-labs/windmill/issues/new?template=bug_report.yml), with
|
||||
clear reproduction steps.
|
||||
- [Feature requests](https://github.com/windmill-labs/windmill/issues/new?template=feature_request.md),
|
||||
including for ideas too big to be a PR.
|
||||
- Questions and feedback on [Discord](https://discord.gg/V7PM2YHsPB).
|
||||
- Contributions to the [Windmill Hub](https://hub.windmill.dev), where scripts, flows and apps are
|
||||
shared with the community.
|
||||
|
||||
## If you do open a PR
|
||||
|
||||
Small, self-contained fixes are still accepted. They require signing the
|
||||
[CLA](./CLA.md), which the CLA bot will prompt for on your first PR.
|
||||
@@ -31,7 +31,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://app.windmill.dev">Try it</a> - <a href="https://www.windmill.dev/">Website</a> - <a href="https://www.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="https://www.windmill.dev/docs/misc/contributing">Contributor's guide</a>
|
||||
<a href="https://app.windmill.dev">Try it</a> - <a href="https://www.windmill.dev/">Website</a> - <a href="https://www.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="./CONTRIBUTING.md">Contributing</a>
|
||||
</p>
|
||||
|
||||
# Windmill - Developer platform for APIs, background jobs, workflows and UIs
|
||||
@@ -62,6 +62,7 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
|
||||
- [Run a local dev setup](#run-a-local-dev-setup)
|
||||
- [Frontend only](#frontend-only)
|
||||
- [Backend + Frontend](#backend--frontend)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [Copyright](#copyright)
|
||||
|
||||
@@ -260,7 +261,7 @@ On self-hosted instances, you might want to import all the approved resource typ
|
||||
| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker |
|
||||
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
|
||||
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
|
||||
| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
|
||||
| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
|
||||
| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker |
|
||||
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
|
||||
| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server |
|
||||
@@ -329,6 +330,12 @@ running options.
|
||||
2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor.
|
||||
7. Windmill should be available at `http://localhost:3000`
|
||||
|
||||
## Contributing
|
||||
|
||||
At this time, we are not seeking outside contribution. Bug reports and feature requests remain very
|
||||
welcome, and small, trivially-verified PRs that fix a problem are still accepted. See
|
||||
[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy.
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/windmill-labs/windmill/graphs/contributors">
|
||||
|
||||
@@ -150,6 +150,21 @@ Global initial fixtures can also seed `liveEditorDrafts` with `type`,
|
||||
currently open script, flow, or raw app editor so cases can test prompts that
|
||||
refer to "this" or the "current" item.
|
||||
|
||||
Global initial fixtures can seed the session's `artifacts` — `{ name, versions: [{ content,
|
||||
note? }], role?, approvedVersion? }`, oldest version first, so the artifact starts with the
|
||||
history `list_artifact_versions` reports — and the `previewTabs` open in its side panel, for
|
||||
cases that run with `runtime.sessionChat: true`. A tab entry names one destination and may
|
||||
be the `active` one:
|
||||
|
||||
```json
|
||||
"previewTabs": [{ "artifact": { "name": "Onboarding plan", "version": 2 }, "active": true }]
|
||||
```
|
||||
|
||||
`page` (`{ href, label }`) and `item` (`{ kind, path }`) tabs work the same way. Tabs are
|
||||
driven by the production tab model, so `open_preview`, `get_preview_status` and
|
||||
`close_page` really open, report and close them, and a `version` is the pin a reader
|
||||
chose in the artifact's version picker — which only `get_preview_status` reports.
|
||||
|
||||
Global initial fixtures can seed `workspace.variables` with
|
||||
`{ path, value, is_secret, description?, labels?, ws_specific? }` entries so cases can
|
||||
read and edit variables that already exist in the workspace. The mock mirrors the real
|
||||
@@ -265,6 +280,10 @@ Typical artifacts by mode:
|
||||
- `history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode
|
||||
- `results/`: local benchmark output and artifacts
|
||||
|
||||
Harness unit tests run in two lanes: `bun test adapters/` for plain TypeScript, and
|
||||
`bun run test:frontend-graph` for `*.vitest.ts` files, which exercise adapters built on
|
||||
frontend code (Svelte runes, SvelteKit aliases) that bun cannot load.
|
||||
|
||||
## Notes
|
||||
|
||||
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createEvalArtifactHelpers } from "./evalArtifactStore";
|
||||
import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
|
||||
|
||||
// A hand-written stand-in for SessionArtifactsStore (bun has no IndexedDB), so nothing
|
||||
// makes it follow that class. A method missing from it surfaces as a tool throwing
|
||||
@@ -19,4 +20,20 @@ describe("eval artifact store", () => {
|
||||
expect(typeof (helpers.artifacts as any)[method]).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("files a plan under the id production derives, seeded or created", async () => {
|
||||
const { helpers, sessionId } = createEvalArtifactHelpers([
|
||||
{ name: "Seeded plan", role: "plan", versions: [{ content: "v1" }] },
|
||||
]);
|
||||
const seeded = await helpers.artifacts.listForSession(sessionId);
|
||||
expect(seeded.map((a: any) => a.id)).toEqual([planArtifactId(sessionId)]);
|
||||
|
||||
const other = createEvalArtifactHelpers();
|
||||
const created = await other.helpers.artifacts.create(other.sessionId, {
|
||||
name: "Plan",
|
||||
content: "v1",
|
||||
role: "plan",
|
||||
});
|
||||
expect(created.id).toBe(planArtifactId(other.sessionId));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,74 @@
|
||||
import { planArtifactId } from "../../../../../frontend/src/lib/components/copilot/chat/artifacts/planIdentity";
|
||||
|
||||
// SessionArtifactsStore can't run here (bun has no IndexedDB, nor the compiled $state runes),
|
||||
// so mirror only the shape the artifact tools call, not its scoping or race handling.
|
||||
export const EVAL_SESSION_ID = "eval-session";
|
||||
export function createEvalArtifactHelpers() {
|
||||
// Cases run concurrently in one process and the preview handlers are registered
|
||||
// process-wide, keyed by session id — so each run needs its own.
|
||||
let sessionSeq = 0;
|
||||
|
||||
/** An artifact the session already holds when the case starts: history has to predate the
|
||||
* run, since one prompt cannot both build a past and reason about it. */
|
||||
export interface SeededArtifact {
|
||||
name: string;
|
||||
role?: "plan";
|
||||
/** Which version the user agreed to. Below the last one means the current text is a
|
||||
* proposal they turned down, which is the state worth seeding. */
|
||||
approvedVersion?: number;
|
||||
/** Oldest first; the last one is the artifact's current content. */
|
||||
versions: Array<{ content: string; note?: string }>;
|
||||
}
|
||||
|
||||
export function createEvalArtifactHelpers(seed: SeededArtifact[] = []) {
|
||||
const sessionId = `eval-session-${sessionSeq++}`;
|
||||
const items = new Map<string, Record<string, any>>();
|
||||
// Snapshots per artifact id, oldest first — the version tools read history from here.
|
||||
const history = new Map<string, Array<Record<string, any>>>();
|
||||
// How a preview-tab fixture names the artifact its tab shows.
|
||||
const seededIds = new Map<string, string>();
|
||||
let seq = 0;
|
||||
for (const entry of seed) {
|
||||
// Derived, not minted: the tools that must not touch the plan recognise it by this id, so
|
||||
// an id of the harness's own would pass a case the real gate refuses. The counter advances
|
||||
// either way, or seeding a plan would renumber the rows around it and collapse the update
|
||||
// order they are sorted on.
|
||||
const n = seq++;
|
||||
const id = entry.role === "plan" ? planArtifactId(sessionId) : `eval-artifact-${n}`;
|
||||
const current = entry.versions.at(-1);
|
||||
if (!current) continue;
|
||||
// A preview tab names the artifact it shows, so a shared name would open whichever
|
||||
// one happened to be seeded last.
|
||||
if (seededIds.has(entry.name)) {
|
||||
throw new Error(
|
||||
`Two seeded artifacts are named "${entry.name}" — a preview tab fixture could not tell them apart`,
|
||||
);
|
||||
}
|
||||
seededIds.set(entry.name, id);
|
||||
items.set(id, {
|
||||
id,
|
||||
sessionId,
|
||||
chatId: "eval-chat",
|
||||
kind: "md",
|
||||
name: entry.name,
|
||||
content: current.content,
|
||||
role: entry.role,
|
||||
approvedVersion: entry.approvedVersion,
|
||||
createdAt: 0,
|
||||
updatedAt: seq,
|
||||
version: entry.versions.length,
|
||||
});
|
||||
history.set(
|
||||
id,
|
||||
entry.versions.map((v, i) => ({
|
||||
key: `${id}:${i + 1}`,
|
||||
artifactId: id,
|
||||
version: i + 1,
|
||||
name: entry.name,
|
||||
content: v.content,
|
||||
savedAt: i,
|
||||
note: v.note,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const snapshotOf = (
|
||||
artifact: Record<string, any>,
|
||||
version: number,
|
||||
@@ -21,14 +84,31 @@ export function createEvalArtifactHelpers() {
|
||||
});
|
||||
const store = {
|
||||
create: async (sessionId: string, input: Record<string, any>) => {
|
||||
// One plan per session, as SessionArtifactsStore enforces it — the tool refuses
|
||||
// first, so reaching this means a case drove create_artifact past that message.
|
||||
if (
|
||||
input.role === "plan" &&
|
||||
[...items.values()].some(
|
||||
(a) => a.sessionId === sessionId && a.role === "plan",
|
||||
)
|
||||
) {
|
||||
throw new Error(`Session ${sessionId} already has a plan document`);
|
||||
}
|
||||
const now = seq++;
|
||||
const artifact = {
|
||||
id: `eval-artifact-${now}`,
|
||||
id:
|
||||
input.role === "plan"
|
||||
? planArtifactId(sessionId)
|
||||
: `eval-artifact-${now}`,
|
||||
sessionId,
|
||||
chatId: input.chatId,
|
||||
kind: input.kind ?? "md",
|
||||
name: input.name,
|
||||
content: input.content,
|
||||
// The plan document is only distinguishable by these, both in the snapshot the
|
||||
// judge reads and in what list_artifacts reports back to the model.
|
||||
role: input.role,
|
||||
approvedVersion: input.approvedVersion,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
version: 1,
|
||||
@@ -58,6 +138,15 @@ export function createEvalArtifactHelpers() {
|
||||
...existing,
|
||||
name: input.name ?? existing.name,
|
||||
content: input.content ?? existing.content,
|
||||
// Carried only onto a version this write produced, as SessionArtifactsStore does:
|
||||
// a rename cannot promote a proposal the user turned down.
|
||||
approvedVersion:
|
||||
input.approvedVersion ??
|
||||
(input.keepApproved &&
|
||||
existing.approvedVersion !== undefined &&
|
||||
contentChanged
|
||||
? version
|
||||
: existing.approvedVersion),
|
||||
updatedAt: seq++,
|
||||
version,
|
||||
};
|
||||
@@ -84,10 +173,12 @@ export function createEvalArtifactHelpers() {
|
||||
return {
|
||||
helpers: {
|
||||
artifacts: store,
|
||||
sessionId: EVAL_SESSION_ID,
|
||||
sessionId,
|
||||
getChatId: () => "eval-chat",
|
||||
openArtifact: () => {},
|
||||
openArtifact: (_id: string, _name: string) => {},
|
||||
},
|
||||
sessionId,
|
||||
seededIds,
|
||||
snapshot: () => [...items.values()],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import {
|
||||
setClosePreviewTabsHandler,
|
||||
setGetPreviewStatusHandler,
|
||||
setOpenPagePreviewHandler,
|
||||
setOpenPreviewHandler,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
import type { GlobalActivePreviewContext } from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
|
||||
import {
|
||||
describePreview,
|
||||
previewTargetForSessionTarget,
|
||||
selectPreviewTabsToClose,
|
||||
SessionPreviewTabs,
|
||||
whereIs,
|
||||
} from "../../../../../frontend/src/lib/components/sessions/sessionPreviewTabs.svelte";
|
||||
import {
|
||||
previewLocationContext,
|
||||
previewLocationLabel,
|
||||
promptSafe,
|
||||
resolvePreviewTab,
|
||||
} from "../../../../../frontend/src/lib/components/sessions/previewRouter";
|
||||
import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter";
|
||||
import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte";
|
||||
|
||||
// The side panel a session chat talks to, driven by the production tab model rather than
|
||||
// by canned tool results — so a case measures what the real open_preview / get_preview_status
|
||||
// / close_page report about the tabs the reader has. sessionRuntime.svelte.ts (the production
|
||||
// owner of these handlers) can't run here: it reaches for IndexedDB, stores and live editors.
|
||||
|
||||
export interface EvalPreviewTabFixture {
|
||||
/** Artifact tab, named by the artifact fixture it shows. `version` pins it, as a reader does. */
|
||||
artifact?: { name: string; version?: number };
|
||||
/** Workspace page tab, e.g. `{ href: "/runs", label: "Runs" }`. */
|
||||
page?: { href: string; label: string };
|
||||
/** Editor tab for a workspace item. */
|
||||
item?: { kind: SessionTarget["kind"]; path: string };
|
||||
/** Tab the reader is looking at. Defaults to the last seeded one. */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// Registered once for the whole process, as production does at module load, and dispatched
|
||||
// by session id: global cases run concurrently, so a per-run registration would have every
|
||||
// case answering out of whichever run registered last.
|
||||
const panels = new Map<string, SessionPreviewTabs>();
|
||||
|
||||
const NO_SESSION = "No active session; the preview panel is unavailable.";
|
||||
|
||||
function panelFor(sessionId: string | undefined): SessionPreviewTabs | undefined {
|
||||
return sessionId ? panels.get(sessionId) : undefined;
|
||||
}
|
||||
|
||||
setGetPreviewStatusHandler((sessionId) => {
|
||||
const owner = panelFor(sessionId);
|
||||
if (!owner) return NO_SESSION;
|
||||
return describePreview(owner.tabs, owner.activeId, !!owner.displayedTab);
|
||||
});
|
||||
|
||||
setOpenPreviewHandler(async ({ sessionId, kind, path }) => {
|
||||
const owner = panelFor(sessionId);
|
||||
if (!owner) return "Error: no active session to open the preview in.";
|
||||
const target = previewTargetForSessionTarget(kind, path);
|
||||
if (!target) {
|
||||
return `Error: ${kind} targets cannot be shown in the preview panel.`;
|
||||
}
|
||||
// The pipeline branch of the production handler waits on an editor that only exists once
|
||||
// a canvas mounts, which never happens here — a pipeline preview reports as any other.
|
||||
const result = owner.open(target);
|
||||
return result.status === "focused"
|
||||
? `A preview tab is already showing ${kind} "${path}" — focused it.`
|
||||
: `Opened ${kind} preview for ${path} in a new tab in the side panel.`;
|
||||
});
|
||||
|
||||
setOpenPagePreviewHandler(({ sessionId, href, label, newTab }) => {
|
||||
const owner = panelFor(sessionId);
|
||||
if (!owner) return undefined;
|
||||
const result = owner.open({ type: "page", href, label }, { forceNewTab: newTab });
|
||||
if (result.status === "focused") {
|
||||
return `A preview tab is already showing ${label} — focused it.`;
|
||||
}
|
||||
if (result.status === "retargeted") {
|
||||
return `Updated the ${label} preview tab with the requested view.`;
|
||||
}
|
||||
return `Opened ${label} in a new preview tab in the side panel.`;
|
||||
});
|
||||
|
||||
setClosePreviewTabsHandler(({ sessionId, all, match }) => {
|
||||
const owner = panelFor(sessionId);
|
||||
if (!owner) return NO_SESSION;
|
||||
if (owner.tabs.length === 0) return "The preview panel has no open tabs.";
|
||||
const labelFor = (t: (typeof owner.tabs)[number]) =>
|
||||
promptSafe(previewLocationLabel(whereIs(t)));
|
||||
const doomed = selectPreviewTabsToClose(owner.tabs, { all, match });
|
||||
if (doomed.length === 0) {
|
||||
return `No open tab matched "${match}". Open tabs: ${owner.tabs.map(labelFor).join(", ")}.`;
|
||||
}
|
||||
const closedLabels = doomed.map(labelFor);
|
||||
for (const t of doomed) owner.close(t.id);
|
||||
return `Closed ${closedLabels.length} preview tab${closedLabels.length === 1 ? "" : "s"} (${closedLabels.join(", ")}).`;
|
||||
});
|
||||
|
||||
export interface EvalPreviewPanel {
|
||||
/** Mirrors production: a written artifact is shown in the panel. `version` carries the
|
||||
* caller's intent for the version picker — `latest` drops a pin the reader had set. */
|
||||
openArtifact: (id: string, name: string, version?: ArtifactVersionTarget) => void;
|
||||
/** What the user message stamps as ACTIVE PREVIEW, as sessionRuntime's resolver reads it. */
|
||||
activePreview: () => GlobalActivePreviewContext | undefined;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createEvalPreviewPanel(input: {
|
||||
sessionId: string;
|
||||
tabs: EvalPreviewTabFixture[];
|
||||
/** Artifact ids by name, from the artifact fixture seeding. */
|
||||
artifactIds: Map<string, string>;
|
||||
}): EvalPreviewPanel {
|
||||
// Nothing durable to write back to, and no debounce worth waiting on.
|
||||
const owner = new SessionPreviewTabs(
|
||||
{ tabs: [], activeId: "", collapsed: false },
|
||||
{ persist: () => {} },
|
||||
0,
|
||||
);
|
||||
// Opening a tab makes it the active one, so the fixture's pick can only be applied once
|
||||
// every tab is seeded — selecting inside the loop would lose to the next open.
|
||||
let requestedActive: string | undefined;
|
||||
for (const fixture of input.tabs) {
|
||||
const opened = seedTab(owner, fixture, input.artifactIds);
|
||||
if (opened && fixture.active) requestedActive = opened;
|
||||
}
|
||||
if (requestedActive) owner.select(requestedActive);
|
||||
// Registered last: seeding throws on a malformed fixture, and this map outlives the run.
|
||||
panels.set(input.sessionId, owner);
|
||||
|
||||
return {
|
||||
openArtifact: (id, name, version) => {
|
||||
owner.open({ type: "artifact", id, name, version });
|
||||
},
|
||||
activePreview: () => {
|
||||
const tab = owner.displayedTab;
|
||||
if (!tab) return undefined;
|
||||
// Artifact and editor tabs are not iframes: they carry no page location, and an
|
||||
// artifact's pinned version reaches the chat only through get_preview_status.
|
||||
if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined;
|
||||
return previewLocationContext(whereIs(tab));
|
||||
},
|
||||
dispose: () => {
|
||||
panels.delete(input.sessionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Seeds one tab through the production open path and returns its id, so a fixture cannot
|
||||
// describe a tab the panel could not have reached on its own.
|
||||
function seedTab(
|
||||
owner: SessionPreviewTabs,
|
||||
fixture: EvalPreviewTabFixture,
|
||||
artifactIds: Map<string, string>,
|
||||
): string | undefined {
|
||||
// A tab shows one destination; the branches below would silently keep the first.
|
||||
const named = [fixture.artifact, fixture.page, fixture.item].filter(Boolean);
|
||||
if (named.length > 1) {
|
||||
throw new Error(
|
||||
"Preview tab fixture sets more than one of artifact, page and item — a tab shows one of them",
|
||||
);
|
||||
}
|
||||
if (fixture.artifact) {
|
||||
const id = artifactIds.get(fixture.artifact.name);
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
`Preview tab fixture references artifact "${fixture.artifact.name}", which no artifact fixture seeds`,
|
||||
);
|
||||
}
|
||||
owner.open({ type: "artifact", id, name: fixture.artifact.name });
|
||||
// A pin is the reader's own pick in the version picker, never a side effect of opening.
|
||||
if (fixture.artifact.version !== undefined) {
|
||||
owner.pinArtifactVersion(id, fixture.artifact.version);
|
||||
}
|
||||
} else if (fixture.page) {
|
||||
owner.open({ type: "page", href: fixture.page.href, label: fixture.page.label });
|
||||
} else if (fixture.item) {
|
||||
const target = previewTargetForSessionTarget(fixture.item.kind, fixture.item.path);
|
||||
if (!target) {
|
||||
throw new Error(`Preview tab fixture has an unpreviewable item kind: ${fixture.item.kind}`);
|
||||
}
|
||||
owner.open(target);
|
||||
} else {
|
||||
throw new Error("Preview tab fixture must set one of artifact, page or item");
|
||||
}
|
||||
return owner.activeId;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
// The panel pulls in the global tool module, which reaches the editor stack it never uses here.
|
||||
vi.mock('monaco-editor', () => ({
|
||||
editor: {},
|
||||
languages: {},
|
||||
KeyCode: {},
|
||||
Uri: { parse: (value: string) => ({ toString: () => value }) },
|
||||
MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 }
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({
|
||||
getTypeScriptWorker: async () => async () => ({}),
|
||||
typescriptVersion: 'test'
|
||||
}))
|
||||
vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) }))
|
||||
vi.mock('$lib/components/vscode', () => ({}))
|
||||
|
||||
const { createEvalPreviewPanel } = await import('./evalPreviewTabs')
|
||||
|
||||
// Every open makes its own tab active, so a fixture's `active` flag only means anything if
|
||||
// it survives the tabs seeded after it. Lose that and a case still runs — against a panel
|
||||
// state its author never described.
|
||||
it('keeps the tab a fixture marks active, not the last one seeded', () => {
|
||||
const panel = createEvalPreviewPanel({
|
||||
sessionId: 'eval-preview-tabs-unit-test',
|
||||
tabs: [
|
||||
{ page: { href: '/runs', label: 'Runs' }, active: true },
|
||||
{ artifact: { name: 'Onboarding plan' } }
|
||||
],
|
||||
artifactIds: new Map([['Onboarding plan', 'eval-artifact-0']])
|
||||
})
|
||||
try {
|
||||
expect(panel.activePreview()?.location).toBe('/runs')
|
||||
} finally {
|
||||
panel.dispose()
|
||||
}
|
||||
})
|
||||
@@ -12,9 +12,19 @@ import {
|
||||
getGlobalDraft,
|
||||
listGlobalDrafts,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
|
||||
import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import { createEvalPlanTools } from "./planModeTools";
|
||||
import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte";
|
||||
import { createEvalArtifactHelpers } from "./evalArtifactStore";
|
||||
import {
|
||||
createEvalArtifactHelpers,
|
||||
type SeededArtifact,
|
||||
} from "./evalArtifactStore";
|
||||
import {
|
||||
createEvalPreviewPanel,
|
||||
type EvalPreviewPanel,
|
||||
type EvalPreviewTabFixture,
|
||||
} from "./evalPreviewTabs";
|
||||
import type { ModeRunContext } from "../../../../core/types";
|
||||
import type { GlobalDraftState } from "../../../../core/validators";
|
||||
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
|
||||
@@ -83,11 +93,18 @@ export interface GlobalEvalOptions {
|
||||
user?: GlobalUserFixture;
|
||||
// Emulate a session chat (preview tools + session prompt); default false = standalone baseline.
|
||||
sessionChat?: boolean;
|
||||
// Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan
|
||||
// tools are offered. Needs sessionChat, which is what plan mode is gated on in production.
|
||||
planMode?: boolean;
|
||||
model?: string;
|
||||
maxIterations?: number;
|
||||
provider?: AIProvider;
|
||||
backend: WindmillBackendSettings;
|
||||
workspaceRoot?: string;
|
||||
// Artifacts the session already holds when the run starts.
|
||||
artifacts?: SeededArtifact[];
|
||||
/** Tabs already open in the side panel, including any artifact version the reader pinned. */
|
||||
previewTabs?: EvalPreviewTabFixture[];
|
||||
runContext?: ModeRunContext;
|
||||
}
|
||||
|
||||
@@ -106,27 +123,67 @@ export async function runGlobalEval(
|
||||
options.workspaceFixtures ?? {},
|
||||
);
|
||||
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
|
||||
// Declared out here only so `finally` can reach it; a malformed fixture throws while
|
||||
// building it, and everything seeded above still has to be torn down.
|
||||
let panel: EvalPreviewPanel | undefined;
|
||||
|
||||
try {
|
||||
const evalArtifacts = createEvalArtifactHelpers(options.artifacts);
|
||||
// Only a session chat has a side panel, so only it gets one here. Seeded tabs would
|
||||
// otherwise vanish without a word, and the case would measure an empty panel.
|
||||
if (!options.sessionChat && options.previewTabs?.length) {
|
||||
throw new Error(
|
||||
"This fixture seeds previewTabs, which only a session chat has — set runtime.sessionChat: true on the case.",
|
||||
);
|
||||
}
|
||||
if (options.sessionChat) {
|
||||
panel = createEvalPreviewPanel({
|
||||
sessionId: evalArtifacts.sessionId,
|
||||
tabs: options.previewTabs ?? [],
|
||||
artifactIds: evalArtifacts.seededIds,
|
||||
});
|
||||
}
|
||||
const model = options.model ?? "claude-haiku-4-5-20251001";
|
||||
const injectActiveEditorContext =
|
||||
process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1";
|
||||
const planMode = options.planMode
|
||||
? createEvalPlanTools({
|
||||
create: evalArtifacts.helpers.artifacts.create,
|
||||
sessionId: evalArtifacts.helpers.sessionId,
|
||||
chatId: evalArtifacts.helpers.getChatId(),
|
||||
})
|
||||
: undefined;
|
||||
// Pass the seeded identity straight to the prompt builder rather than mutating
|
||||
// the process-global `userStore`, so concurrent cases never race on it.
|
||||
const evalArtifacts = createEvalArtifactHelpers();
|
||||
const baseSystemMessage = prepareGlobalSystemMessage(undefined, {
|
||||
user: options.user,
|
||||
previewTools: options.sessionChat ?? false,
|
||||
});
|
||||
const rawResult = await runEval({
|
||||
userPrompt,
|
||||
systemMessage: prepareGlobalSystemMessage(undefined, {
|
||||
user: options.user,
|
||||
previewTools: options.sessionChat ?? false,
|
||||
systemMessage: baseSystemMessage,
|
||||
// Re-derived per request, as production's getter is: the instructions have to leave
|
||||
// the prompt when the plan is approved, or the model is still told it may not build
|
||||
// while the gate has already opened.
|
||||
getSystemMessage: planMode
|
||||
? () =>
|
||||
planMode.isPlanModeActive()
|
||||
? appendPlanModeInstructions(baseSystemMessage, 0)
|
||||
: baseSystemMessage
|
||||
: undefined,
|
||||
isPlanModeActive: planMode?.isPlanModeActive,
|
||||
isToolAvailable: planMode?.isToolAvailable,
|
||||
userMessage: prepareGlobalUserMessage(userPrompt, [], {
|
||||
...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}),
|
||||
activePreview: panel?.activePreview(),
|
||||
}),
|
||||
userMessage: prepareGlobalUserMessage(
|
||||
userPrompt,
|
||||
[],
|
||||
injectActiveEditorContext ? { workspace: workspaceRoot } : {},
|
||||
),
|
||||
tools: getGlobalEvalTools(options.sessionChat ?? false),
|
||||
helpers: evalArtifacts.helpers,
|
||||
tools: [
|
||||
...getGlobalEvalTools(options.sessionChat ?? false),
|
||||
...(planMode?.tools ?? []),
|
||||
],
|
||||
helpers: panel
|
||||
? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact }
|
||||
: evalArtifacts.helpers,
|
||||
apiKey,
|
||||
getOutput: async () => ({
|
||||
...(await collectGlobalDraftState(workspaceRoot)),
|
||||
@@ -159,6 +216,7 @@ export async function runGlobalEval(
|
||||
finalContextTokens: rawResult.finalContextTokens,
|
||||
};
|
||||
} finally {
|
||||
panel?.dispose();
|
||||
clearGlobalDrafts(workspaceRoot);
|
||||
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
|
||||
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
|
||||
derivePlanTitle,
|
||||
exitPlanModeArgs,
|
||||
planSummaryOf,
|
||||
} from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
|
||||
import { PLAN_MODE_MESSAGES } from "../../../../../frontend/src/lib/components/copilot/chat/planModeMessages";
|
||||
import { createToolDef } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
|
||||
|
||||
/**
|
||||
* `exit_plan_mode` built from the production schema, description and messages, so a case
|
||||
* exercises the real gate and wording with the posture living here rather than on the
|
||||
* manager. It resolves immediately — the runners define no `requestConfirmation`, so the
|
||||
* plan is always approved and a refused one cannot be expressed.
|
||||
*/
|
||||
export function createEvalPlanTools(artifacts: {
|
||||
create: (
|
||||
sessionId: string,
|
||||
input: Record<string, unknown>,
|
||||
) => Promise<{ id: string; name: string }>;
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
}): {
|
||||
tools: ProductionTool<{}>[];
|
||||
isPlanModeActive: () => boolean;
|
||||
isToolAvailable: (name: string) => boolean;
|
||||
} {
|
||||
let planActive = true;
|
||||
return {
|
||||
isPlanModeActive: () => planActive,
|
||||
// Withdrawn on approval, as production's tool getter does it: leaving it advertised
|
||||
// invites a second hand-over of a plan already agreed, which would write a duplicate.
|
||||
// Production would offer enter_plan_mode in its place; these cases stop at the first
|
||||
// hand-over, so a fresh planning round belongs to a case of its own.
|
||||
isToolAvailable: (name) => name !== EXIT_PLAN_MODE_TOOL || planActive,
|
||||
// Production offers one plan tool at a time and these cases start in plan mode, so
|
||||
// enter_plan_mode would only invite a turn spent entering a posture already held.
|
||||
tools: [
|
||||
{
|
||||
def: createToolDef(
|
||||
exitPlanModeArgs,
|
||||
EXIT_PLAN_MODE_TOOL,
|
||||
EXIT_PLAN_MODE_TOOL_DESCRIPTION,
|
||||
),
|
||||
// Carries the safety tag for the same reason production does: it is the only way out
|
||||
// of the posture, so the gate must not refuse it.
|
||||
planModeSafe: true,
|
||||
fn: async ({ args }) => {
|
||||
const summary = planSummaryOf(args);
|
||||
if (!summary?.trim()) {
|
||||
return PLAN_MODE_MESSAGES.missingSummary;
|
||||
}
|
||||
planActive = false;
|
||||
await artifacts.create(artifacts.sessionId, {
|
||||
name: derivePlanTitle(summary),
|
||||
content: summary,
|
||||
kind: "md",
|
||||
role: "plan",
|
||||
approvedVersion: 1,
|
||||
chatId: artifacts.chatId,
|
||||
});
|
||||
return PLAN_MODE_MESSAGES.approvedWithDoc;
|
||||
},
|
||||
},
|
||||
] as ProductionTool<{}>[],
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,15 @@ export interface RunEvalParams<THelpers, TOutput> {
|
||||
getOutput: () => TOutput | Promise<TOutput>;
|
||||
/** Model and Windmill backend configuration */
|
||||
options: EvalRunnerOptions;
|
||||
/** Drives the production plan-mode gate in processToolCall. Absent leaves it inert,
|
||||
* which is what every mode but an opted-in global case wants. */
|
||||
isPlanModeActive?: () => boolean;
|
||||
/** Which of `tools` the model is offered on this request. Absent offers all of them. */
|
||||
isToolAvailable?: (name: string) => boolean;
|
||||
/** Re-read before every request, as production's systemMessage getter is. Needed when a
|
||||
* tool changes what the prompt should say — plan mode's instructions have to come back
|
||||
* out once the plan is approved. Falls back to the fixed `systemMessage`. */
|
||||
getSystemMessage?: () => ChatCompletionSystemMessageParam;
|
||||
onAssistantMessageStart?: () => void;
|
||||
onAssistantToken?: (token: string) => void;
|
||||
onAssistantMessageEnd?: () => void;
|
||||
@@ -68,6 +77,9 @@ export async function runEval<THelpers, TOutput>(
|
||||
onAssistantToken,
|
||||
onAssistantMessageEnd,
|
||||
onToolCall,
|
||||
isPlanModeActive,
|
||||
isToolAvailable,
|
||||
getSystemMessage,
|
||||
} = params;
|
||||
let shouldEmitMessageStart = true;
|
||||
|
||||
@@ -119,6 +131,7 @@ export async function runEval<THelpers, TOutput>(
|
||||
} = {
|
||||
setToolStatus: () => {},
|
||||
removeToolStatus: () => {},
|
||||
isPlanModeActive,
|
||||
onNewToken: (token: string) => {
|
||||
if (shouldEmitMessageStart) {
|
||||
onAssistantMessageStart?.();
|
||||
@@ -140,8 +153,17 @@ export async function runEval<THelpers, TOutput>(
|
||||
try {
|
||||
const result = await runChatLoop({
|
||||
messages,
|
||||
systemMessage,
|
||||
tools: wrappedTools,
|
||||
get systemMessage() {
|
||||
return getSystemMessage?.() ?? systemMessage;
|
||||
},
|
||||
// Re-derived per request, as `systemMessage` is: a tool the posture has withdrawn
|
||||
// must leave the schema too, or the model keeps being offered a call the run has
|
||||
// moved past — and the token counts a case reports include a tool it cannot use.
|
||||
get tools() {
|
||||
return isToolAvailable
|
||||
? wrappedTools.filter((t) => isToolAvailable(t.def.function.name))
|
||||
: wrappedTools;
|
||||
},
|
||||
helpers,
|
||||
abortController,
|
||||
callbacks,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import frontendConfig from '../../../frontend/vite.config.js'
|
||||
|
||||
// Harness unit tests that reach into the frontend module graph. They can't run under
|
||||
// `bun test` (Svelte runes and the SvelteKit aliases both need this build), so they are
|
||||
// named `*.vitest.ts` — bun's `*.test.ts` sweep skips them and this config claims them.
|
||||
const FRONTEND_VITE_CONFIG_PATH = fileURLToPath(new URL('../../../frontend/vite.config.js', import.meta.url))
|
||||
const FRONTEND_TEST_SETUP_PATH = fileURLToPath(
|
||||
new URL('../../../frontend/src/lib/test-setup.ts', import.meta.url)
|
||||
)
|
||||
const UNIT_TESTS = fileURLToPath(new URL('./**/*.vitest.ts', import.meta.url))
|
||||
|
||||
const config = {
|
||||
...frontendConfig,
|
||||
test: {
|
||||
...frontendConfig.test,
|
||||
projects: [
|
||||
{
|
||||
extends: FRONTEND_VITE_CONFIG_PATH,
|
||||
test: {
|
||||
name: 'server',
|
||||
environment: 'node',
|
||||
include: [UNIT_TESTS],
|
||||
setupFiles: [FRONTEND_TEST_SETUP_PATH]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
@@ -1180,6 +1180,7 @@
|
||||
- id: global-closepage1-close-runs-tab
|
||||
prompt: |-
|
||||
You just opened the runs page for me in the side panel. Close that tab, I'm done looking at it.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/preview_runs_tab.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
sessionChat: true
|
||||
@@ -1233,6 +1234,32 @@
|
||||
- creates one artifact and revises it rather than creating a second artifact
|
||||
- each revision carries a short description of what changed
|
||||
|
||||
# A reader who pins an older version in the artifact's picker is looking at something the
|
||||
# artifact tools never report: an artifact tab carries no ACTIVE PREVIEW section, so the pin
|
||||
# reaches the chat through get_preview_status alone. Asked what is on screen, the model has
|
||||
# to read the panel instead of answering from the artifact's own history.
|
||||
|
||||
- id: global-artifact-pinned-version-question
|
||||
prompt: |-
|
||||
Which version of the onboarding plan am I looking at right now?
|
||||
initial: ai_evals/fixtures/frontend/global/initial/artifact_onboarding_plan_pinned_v2.json
|
||||
runtime:
|
||||
maxTurns: 6
|
||||
sessionChat: true
|
||||
validate:
|
||||
draftCountExactly: 0
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- get_preview_status
|
||||
forbiddenToolsUsed:
|
||||
- create_artifact
|
||||
- update_artifact
|
||||
- deploy_workspace_item
|
||||
skipJudge: true
|
||||
judgeChecklist:
|
||||
- answers that the panel is showing version 2, not the latest version 5
|
||||
- does not edit or re-create the artifact
|
||||
|
||||
# --- Documentation search (search_docs) ---
|
||||
# Pure product-knowledge questions: the assistant should consult the docs via
|
||||
# search_docs and answer conversationally, not draft or mutate anything. No
|
||||
@@ -1756,8 +1783,55 @@
|
||||
judgeChecklist:
|
||||
- saves the plan as a markdown artifact via create_artifact rather than only replying inline
|
||||
- the artifact content has a title, a one-line summary, and three or four bullet steps for onboarding
|
||||
- the artifact is registered as the session's plan (role "plan"), not as an ordinary note - the user asked for the plan they will come back to and revise
|
||||
- does not create a flow or script draft yet
|
||||
|
||||
- id: global-planmode1-hands-over-a-plan
|
||||
prompt: |-
|
||||
Our support inbox is a mess. I want incoming emails triaged by urgency and routed to the
|
||||
right team, with anything urgent also posted to Slack.
|
||||
Work out how you'd build this in Windmill.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
sessionChat: true
|
||||
planMode: true
|
||||
# No draft assertion: approving the plan opens the gate mid-run, and building from there is
|
||||
# what production asks for, so a draft is not a failure. The gate itself is covered by
|
||||
# shared.test.ts; what only a real model can show is whether it researches and hands over a
|
||||
# usable plan instead of guessing at one.
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- exit_plan_mode
|
||||
# Not "saves the plan as an artifact": exit_plan_mode writes it, so the harness would
|
||||
# satisfy that on every run the tool is called at all — it grades itself, not the model.
|
||||
judgeChecklist:
|
||||
- the plan covers classifying an incoming email by urgency, routing it to a team, and posting urgent ones to Slack
|
||||
- the plan is specific about what would be built in Windmill (a flow and its steps, or the scripts involved)
|
||||
|
||||
- id: global-planmode2-sketches-while-planning
|
||||
prompt: |-
|
||||
We're moving our nightly CSV export off a schedule and onto a webhook the vendor calls
|
||||
when their file is ready. Work out how you'd rebuild it in Windmill — and draw me the
|
||||
shape of it before you write anything, I find that easier to react to than prose.
|
||||
initial: ai_evals/fixtures/frontend/global/initial/user_admin_evals_folder.json
|
||||
runtime:
|
||||
maxTurns: 10
|
||||
sessionChat: true
|
||||
planMode: true
|
||||
# Both tools, because either alone is a different behaviour: create_artifact without
|
||||
# exit_plan_mode means the model filed the plan as the drawing, and exit_plan_mode without
|
||||
# create_artifact means the posture blocked the drawing it was asked for.
|
||||
toolExpect:
|
||||
requiredToolsUsed:
|
||||
- create_artifact
|
||||
- exit_plan_mode
|
||||
judgeChecklist:
|
||||
- saves a diagram of the proposed design as an artifact rather than only describing it in chat
|
||||
- the diagram covers the webhook that starts the run and the steps that replace the nightly schedule
|
||||
- hands the plan over with exit_plan_mode instead of leaving it in the artifact
|
||||
- does not register the diagram as the session's plan document
|
||||
|
||||
- id: global-npm1-script-search-package
|
||||
prompt: |-
|
||||
Find a good npm package for parsing RSS/Atom feeds and use it to create a draft Bun script
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface EvalCaseRuntimeSpec {
|
||||
appContext?: EvalCaseRuntimeAppContextSpec;
|
||||
// Global mode: run as a session chat (preview tools + session prompt) vs the standalone chat.
|
||||
sessionChat?: boolean;
|
||||
// Global session chats: start the case in plan mode, so the workspace-changing tools are
|
||||
// refused until the model hands over a plan with exit_plan_mode.
|
||||
planMode?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowValidationSpec {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"is_admin": true
|
||||
},
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "Onboarding plan",
|
||||
"versions": [
|
||||
{
|
||||
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Create the customer record\n- Send the welcome email\n"
|
||||
},
|
||||
{
|
||||
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n",
|
||||
"note": "Added domain verification"
|
||||
},
|
||||
{
|
||||
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record\n- Send the welcome email\n- Schedule the 7-day check-in\n",
|
||||
"note": "Added the 7-day check-in"
|
||||
},
|
||||
{
|
||||
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n",
|
||||
"note": "Named the CRM as the record store"
|
||||
},
|
||||
{
|
||||
"content": "# Onboarding plan\n\nA staged rollout of the customer onboarding flow.\n\n- Collect the signup form\n- Verify the company domain\n- Create the customer record in the CRM\n- Send the welcome email\n- Schedule the 7-day check-in\n- Hand over to the account manager\n",
|
||||
"note": "Added the account-manager handover"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"previewTabs": [
|
||||
{
|
||||
"artifact": { "name": "Onboarding plan", "version": 2 },
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"is_admin": true
|
||||
},
|
||||
"previewTabs": [
|
||||
{
|
||||
"page": { "href": "/runs", "label": "Runs" },
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,9 +6,15 @@ import {
|
||||
type GlobalLiveEditorDraftFixture,
|
||||
type GlobalUserFixture,
|
||||
} from "../adapters/frontend/core/global/globalEvalRunner";
|
||||
import type { SeededArtifact } from "../adapters/frontend/core/global/evalArtifactStore";
|
||||
import type { EvalPreviewTabFixture } from "../adapters/frontend/core/global/evalPreviewTabs";
|
||||
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
|
||||
import type { FrontendEvalModelConfig } from "../core/models";
|
||||
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
|
||||
import type {
|
||||
BenchmarkArtifactFile,
|
||||
GlobalValidationSpec,
|
||||
ModeRunner,
|
||||
} from "../core/types";
|
||||
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
|
||||
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
|
||||
import { getFrontendApiKey } from "./frontendCommon";
|
||||
@@ -17,6 +23,8 @@ export interface GlobalInitialFixture {
|
||||
workspace?: BenchmarkWorkspaceRunnables;
|
||||
liveEditorDrafts?: GlobalLiveEditorDraftFixture[];
|
||||
user?: GlobalUserFixture;
|
||||
artifacts?: SeededArtifact[];
|
||||
previewTabs?: EvalPreviewTabFixture[];
|
||||
}
|
||||
|
||||
export function createGlobalModeRunner(
|
||||
@@ -41,7 +49,10 @@ export function createGlobalModeRunner(
|
||||
workspaceFixtures: initial?.workspace,
|
||||
liveEditorDrafts: initial?.liveEditorDrafts,
|
||||
user: initial?.user,
|
||||
artifacts: initial?.artifacts,
|
||||
previewTabs: initial?.previewTabs,
|
||||
sessionChat: context.evalCase?.runtime?.sessionChat,
|
||||
planMode: context.evalCase?.runtime?.planMode,
|
||||
maxIterations: context.evalCase?.runtime?.maxTurns,
|
||||
provider: modelConfig.provider,
|
||||
model: modelConfig.model,
|
||||
@@ -81,7 +92,9 @@ export function createGlobalModeRunner(
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
|
||||
async function loadGlobalInitialFixture(
|
||||
path: string,
|
||||
): Promise<GlobalInitialFixture> {
|
||||
if ((await stat(path)).isDirectory()) {
|
||||
const { initialFrontend, initialBackend, initialDatatables } =
|
||||
await loadAppFixtureForEval(path);
|
||||
@@ -104,14 +117,20 @@ async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixt
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path, "utf8"),
|
||||
) as GlobalInitialFixture;
|
||||
return {
|
||||
workspace: parsed.workspace ?? {},
|
||||
liveEditorDrafts: parsed.liveEditorDrafts ?? [],
|
||||
user: parsed.user,
|
||||
artifacts: parsed.artifacts,
|
||||
previewTabs: parsed.previewTabs ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
|
||||
async function loadGlobalExpectedFixture(
|
||||
path: string,
|
||||
): Promise<GlobalDraftState> {
|
||||
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"cli": "bun cli/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test:frontend-graph": "cd ../frontend && node_modules/.bin/vitest run --project server --config ../ai_evals/adapters/frontend/vitest.unit.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.25",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./adapters/frontend/vitest.config.ts"
|
||||
"./**/*.vitest.ts",
|
||||
"./adapters/frontend/vitest.config.ts",
|
||||
"./adapters/frontend/vitest.unit.config.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12 WHERE worker = $6",
|
||||
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -16,10 +16,11 @@
|
||||
"Float4",
|
||||
"Float4",
|
||||
"Float4",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a41c4cbaffdb714e4a963557de5a4011744d684eb24e03cb4beae6a512613159"
|
||||
"hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE job_tree AS (\n SELECT id, tag FROM v2_job WHERE id = $2 AND workspace_id = $1\n UNION\n SELECT j.id, j.tag FROM v2_job j JOIN job_tree t ON j.parent_job = t.id\n WHERE j.workspace_id = $1\n )\n SELECT\n a.path,\n a.kind AS \"kind!: windmill_common::assets::AssetKind\",\n -- Several jobs of the tree touch one asset, each recording its own\n -- access. A job that recorded none contributes nothing rather than\n -- erasing a sibling's, so an all-null group is the only unknown one.\n -- Grouping here, not in Rust, is what makes LIMIT count assets: the\n -- retention keeps up to ten job rows per asset.\n COALESCE(bool_or(a.usage_access_type IN ('r', 'rw')), false) AS \"any_read!\",\n COALESCE(bool_or(a.usage_access_type IN ('w', 'rw')), false) AS \"any_write!\"\n FROM asset a JOIN job_tree t ON a.usage_path = t.id::text\n WHERE a.workspace_id = $1 AND a.usage_kind = 'job'\n AND ($3::text[] IS NULL OR t.tag = ANY($3))\n GROUP BY a.path, a.kind\n ORDER BY a.path, a.kind\n LIMIT $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "kind!: windmill_common::assets::AssetKind",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable",
|
||||
"volume",
|
||||
"dbt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "any_read!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "any_write!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Uuid",
|
||||
"TextArray",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "0df1c23ac429b5807d42e1da5ee2e2176a8a774f107bece5804cc7b221365c89"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT 'schedule' AS \"kind!\", COUNT(*)::BIGINT AS \"count!\" FROM schedule\n UNION ALL SELECT 'http', COUNT(*)::BIGINT FROM http_trigger\n UNION ALL SELECT 'websocket', COUNT(*)::BIGINT FROM websocket_trigger\n UNION ALL SELECT 'kafka', COUNT(*)::BIGINT FROM kafka_trigger\n UNION ALL SELECT 'nats', COUNT(*)::BIGINT FROM nats_trigger\n UNION ALL SELECT 'postgres', COUNT(*)::BIGINT FROM postgres_trigger\n UNION ALL SELECT 'mqtt', COUNT(*)::BIGINT FROM mqtt_trigger\n UNION ALL SELECT 'sqs', COUNT(*)::BIGINT FROM sqs_trigger\n UNION ALL SELECT 'gcp', COUNT(*)::BIGINT FROM gcp_trigger\n UNION ALL SELECT 'azure', COUNT(*)::BIGINT FROM azure_trigger\n UNION ALL SELECT 'amqp', COUNT(*)::BIGINT FROM amqp_trigger\n UNION ALL SELECT 'email', COUNT(*)::BIGINT FROM email_trigger\n -- Grouped, not a single 'native' key: these fire as nextcloud/google/github,\n -- so a lone key would not line up with the `trigger`/`fired` series.\n UNION ALL SELECT service_name::text, COUNT(*)::BIGINT FROM native_trigger GROUP BY service_name\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1d346a14ad5586af347b8e7ac413500a39efa20e4915ffa56fd40537597db36e"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT username, added_via\n FROM usr\n WHERE workspace_id = $1 AND email = $2\n AND added_via->>'source' = 'instance_group'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "added_via",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "2132a715995f2775917c59c01ba66f2e472a2347f25cc7109f36a805806ee6e2"
|
||||
}
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT email FROM usr WHERE added_via->>'source' = 'instance_group' AND added_via->>'group' = $1",
|
||||
"query": "SELECT instance_role FROM instance_group WHERE name = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"name": "instance_role",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
@@ -15,8 +15,8 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "9530b234b2ac8f7360552be5e6bb25270e6b4185ce5c7f3c6edd9fbaabf77544"
|
||||
"hash": "236028886d13526daa184f9e6d0a4b2ae43fbaf7f0bbbc8246c5e2d73b6f6aee"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*)::BIGINT AS \"count!\" FROM flow\n WHERE archived = false AND pg_column_size(value) >= $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "255d37bb63595ebfcc61582d0b5e265b861b8b4d650435533e90eeb1d5ee3a68"
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT resource.path,\n COALESCE(left(pretty.value, $3), '') as \"value!\",\n COALESCE(length(pretty.value) > $3, false) as \"truncated!\"\n FROM resource, LATERAL (SELECT jsonb_pretty(resource.value) as value OFFSET 0) pretty\n WHERE workspace_id = $1 LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "truncated!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2d5272e17f5c96185c7f655a6580f1e849dca05d1418798f5ea11dd91c9477bc"
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id FROM workspace_settings WHERE auto_invite->'instance_groups' IS NOT NULL AND auto_invite->'instance_groups' ? $1",
|
||||
"query": "SELECT workspace_id FROM workspace_settings WHERE auto_invite->'instance_groups' ?| $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -11,12 +11,12 @@
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c7c0b7f760f9616ec4a18a8916226b65990f5055c1bc25eedefd303be75f553f"
|
||||
"hash": "374863f06094e404523e700df832243894e40a081fd2e7f95b466612aed054de"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n COUNT(*) FILTER (WHERE slack_command_script IS NOT NULL)::BIGINT AS \"slack!\",\n COUNT(*) FILTER (WHERE teams_command_script IS NOT NULL)::BIGINT AS \"teams!\"\n FROM workspace_settings",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "slack!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "teams!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3b0eb0571f287eb64c84cf2c8303401af7b78d0fb6cbaa10ccb90769320c8c7a"
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE auto_invite->'instance_groups' ? $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instance_groups_json",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6"
|
||||
}
|
||||
+3
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT igroup FROM email_to_igroup WHERE email = $1",
|
||||
"query": "SELECT name FROM instance_group WHERE name = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "igroup",
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
@@ -18,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b"
|
||||
"hash": "4348832b4b99021b19a752a0375f7728fb10035ff4a71b8e0242e1184d192a32"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name FROM instance_group WHERE name = ANY($1) ORDER BY name FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "43a689277803e5e2204e10263a5749675652c23a231fce65257b053b6faad231"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT workspace_id, username, email\n FROM usr\n WHERE email = $1\n AND added_via->>'source' = 'instance_group'\n AND added_via->>'group' = $2\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "472f224dc9e17d2c50cb9db13c34cc1fb9adb6d0ea36cf223541adb7cac17bdd"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT auto_invite->'instance_groups' as \"groups: serde_json::Value\",\n auto_invite->'instance_groups_roles' as \"roles: serde_json::Value\"\n FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "groups: serde_json::Value",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "roles: serde_json::Value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "49bf26ae4b7e3421507f9e7e42c59ad7e0f481a9550e0f70e1145d5e541bd6e5"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture WHERE created_at > now() - interval '30 days' GROUP BY trigger_kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5344f222417c28efd4f724cbd83382fc69a223dfbb91ab40df895ab60d0f6228"
|
||||
}
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE\n auto_invite->'instance_groups' IS NOT NULL\n AND auto_invite->'instance_groups' ? $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instance_groups_json",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT config AS \"config!\" FROM config WHERE name LIKE 'worker__%' AND config IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "config!",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "6c6b4bd4bd19878fce25d3a8a5ee02686b358b616c318e4bfca084d96b38f1c4"
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT username, email, is_admin, operator,\n added_via->>'group' as granting_group\n FROM usr\n WHERE workspace_id = $1 AND added_via->>'source' = 'instance_group'\n ORDER BY email",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operator",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "granting_group",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6fda4517a72b25b0eab47bc69127ee45467d4d45239fb556534429b03442b27b"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO feature_usage (feature, kind, key, value)\n SELECT * FROM UNNEST($1::text[], $2::text[], $3::text[], $4::bigint[])\n ON CONFLICT (feature, kind, key, entity_id, day)\n DO UPDATE SET value = feature_usage.value + EXCLUDED.value, updated_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Int8Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "772dc28e57666282d8993268843d3a87e8899b968827251745f998e4dc25863a"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
"query": "UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3 AND enabled = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2ee6d24b95cdda151585dcff19f8e7c931785fc21f7bbe9c3a82671943ced0ea"
|
||||
"hash": "7947ffe31b8e6f4a38fba9d9caf434fd409ea2806a7c720c2b1d76b2e7db6c32"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n (SELECT COUNT(*) FROM script\n WHERE dedicated_worker = true AND archived = false AND deleted = false)::BIGINT AS \"scripts!\",\n (SELECT COUNT(*) FROM flow WHERE dedicated_worker = true AND archived = false)::BIGINT AS \"flows!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "scripts!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flows!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7b3eadb62ddd07e5e12eb8b5150ddce33008b61bbdab4006194ca7ef5b754802"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr SET added_via = $1 WHERE workspace_id = $2 AND email = $3",
|
||||
"query": "UPDATE usr SET added_via = $1 WHERE workspace_id = $2 AND email = $3 AND added_via->>'source' = 'instance_group'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f582cac90b4b7d732956b74eebc51323ef8acd3f627e7517451fcc72998d22bc"
|
||||
"hash": "815c5e8fd91dc119a803f4f1f56b8016bdaabfa2ff61a1abe8f73f5a7336ab61"
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM global_settings WHERE name = 'smtp_settings'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "83ec97f6aad154e0e06ee05a3647dab8f89b1b2d7a569c7eac8c4169e37b9f8b"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "scope_kind",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "launched_by_app",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9"
|
||||
}
|
||||
+5
-4
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT auto_invite->'instance_groups' FROM workspace_settings WHERE workspace_id = $1",
|
||||
"query": "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"name": "policy",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "75e740531bf794a8568350348253612cfbdecb9fb9cf768f431d4dbd4cd56cfb"
|
||||
"hash": "87873ad46b94e26f840d1710146e90c639beb2a5389432e64ecd94adbf154516"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO trigger_history\n (workspace_id, trigger_kind, path, operation, source, username, changes)\n SELECT $1, $2, p, $3, $4, $5, $6 FROM unnest($7::text[]) p",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "928767710fb8b7dc0b1edc897d8ce9b6b59ae2f63e684d3db6eecbcffd767711"
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "9d9fbcb598c582a29d65be87a1e35baa410f93be3cac4b8dfd33ddbef446d3fe"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT trigger_kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM capture_config GROUP BY trigger_kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a2f047f9ca4b8a47c985fa092ba0d2dc54f7169af1c940b84345c477865ae82c"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(\n CASE WHEN elem #>> '{}' = $1 THEN to_jsonb($2::text) ELSE elem END), '[]'::jsonb)\n FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem)\n ),\n '{instance_groups_roles}',\n CASE WHEN COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) ? $1\n THEN (COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) - $1)\n || jsonb_build_object($2::text, auto_invite->'instance_groups_roles'->$1)\n ELSE COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb)\n END\n )\n WHERE auto_invite->'instance_groups' ? $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ae1973cf7dda23c1583521c7edd1a7e3beb695edaa00adf875cfdbb81ebe96dc"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name FROM instance_group WHERE name <> ALL($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bbc2638aae4fb3556c8d876e4efd402c7b7c93ff9fd89364afd41470a92a5e2d"
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode) VALUES ($1, $2, COALESCE($3, 'NO IP'), $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (worker)\n DO UPDATE set ping_at = now(), worker_instance = EXCLUDED.worker_instance, ip = COALESCE($3, worker_ping.ip), custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_worker = EXCLUDED.dedicated_worker, dedicated_workers = EXCLUDED.dedicated_workers, wm_version = EXCLUDED.wm_version, vcpus = COALESCE(EXCLUDED.vcpus, worker_ping.vcpus), memory = COALESCE(EXCLUDED.memory, worker_ping.memory), job_isolation = EXCLUDED.job_isolation, native_mode = EXCLUDED.native_mode, current_job_id = NULL, current_job_workspace_id = NULL\n RETURNING jobs_executed",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "jobs_executed",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c748617e060bc41b5922df4b433f20b6971b993e0671b6ccb45db5ef028550bc"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT pg_advisory_xact_lock(hashtext('reconcile_workspace_instance_groups'), hashtext($1))",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "pg_advisory_xact_lock",
|
||||
"type_info": "Void"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d069ad741996e3ea992bcfb33e290ba5b87be1c047c189c9b8eb13da97e2085d"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, igroup FROM email_to_igroup WHERE igroup = ANY($1)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "igroup",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d1f701e81fc98933802356f292455da604367ef35893c2bb095bcd637567b114"
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem WHERE elem #>> '{}' != $1)\n ),\n '{instance_groups_roles}',\n COALESCE(auto_invite->'instance_groups_roles', '{}'::jsonb) - $1\n )\n WHERE auto_invite->'instance_groups' IS NOT NULL AND auto_invite->'instance_groups' ? $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dab323eda1fcaff0435e98d77e544ed5d63dd6023b1dab77d5f188b299f03b9d"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value\n WHERE jsonb_typeof(global_settings.value) <> 'string'\n RETURNING value",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "db9b48f91a2387e08a2eaa5bda344edf83bfcb8c8c330d524ed4eaafedbdbc0e"
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH scanned AS (\n SELECT a.* FROM (\n SELECT value FROM flow\n WHERE archived = false AND pg_column_size(value) < $1\n LIMIT $2\n ) f,\n LATERAL (\n SELECT\n bool_or(m->'value'->>'type' = 'forloopflow') AS forloop,\n bool_or(m->'value'->>'type' = 'whileloopflow') AS whileloop,\n bool_or(m->'value'->>'type' = 'branchall') AS branchall,\n bool_or(m->'value'->>'type' = 'branchall'\n AND m->'value'->>'parallel' = 'false') AS branchall_seq,\n bool_or(m->'value'->>'type' = 'branchone') AS branchone,\n bool_or(m->'value'->>'type' = 'aiagent') AS aiagent,\n bool_or(m->'value'->>'type' = 'flow') AS subflow,\n bool_or(m->'value'->>'type' = 'identity') AS identity,\n bool_or(m->'value'->>'is_trigger' = 'true') AS trigger_step,\n bool_or(m->'value'->>'squash' = 'true') AS squash,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'parallel' = 'true') AS parallel_loop,\n bool_or(m->'value'->>'type' IN ('forloopflow', 'whileloopflow')\n AND m->'value'->>'skip_failures' = 'false') AS keep_failures,\n bool_or(m->'value' ? 'parallelism') AS parallelism,\n bool_or(m ? 'sleep') AS sleep,\n bool_or(m ? 'cache_ttl') AS cache,\n bool_or(m->'mock'->>'enabled' = 'true') AS mock,\n bool_or(m ? 'suspend') AS suspend,\n bool_or(m ? 'retry') AS retry,\n bool_or(m ? 'timeout') AS timeout,\n bool_or(m ? 'priority') AS priority,\n bool_or(m ? 'debouncing') AS debounce,\n bool_or(m ? 'delete_after_secs') AS lifetime,\n bool_or(m->>'continue_on_error' = 'true') AS continue_on_error,\n bool_or(m ? 'stop_after_if' OR m ? 'stop_after_all_iters_if') AS early_stop,\n bool_or(m ? 'skip_if') AS skip\n FROM jsonb_path_query(f.value, '$.**.modules[*]') m\n ) a\n )\n SELECT\n COUNT(*)::BIGINT AS \"flows_scanned!\",\n COUNT(*) FILTER (WHERE forloop)::BIGINT AS \"forloopflow!\",\n COUNT(*) FILTER (WHERE whileloop)::BIGINT AS \"whileloopflow!\",\n COUNT(*) FILTER (WHERE branchall)::BIGINT AS \"branchall!\",\n COUNT(*) FILTER (WHERE branchall_seq)::BIGINT AS \"branchall_sequential!\",\n COUNT(*) FILTER (WHERE branchone)::BIGINT AS \"branchone!\",\n COUNT(*) FILTER (WHERE aiagent)::BIGINT AS \"aiagent!\",\n COUNT(*) FILTER (WHERE subflow)::BIGINT AS \"subflow!\",\n COUNT(*) FILTER (WHERE identity)::BIGINT AS \"identity!\",\n COUNT(*) FILTER (WHERE trigger_step)::BIGINT AS \"trigger_step!\",\n COUNT(*) FILTER (WHERE squash)::BIGINT AS \"squash!\",\n COUNT(*) FILTER (WHERE parallel_loop)::BIGINT AS \"parallel_loop!\",\n COUNT(*) FILTER (WHERE keep_failures)::BIGINT AS \"keep_failures!\",\n COUNT(*) FILTER (WHERE parallelism)::BIGINT AS \"parallelism!\",\n COUNT(*) FILTER (WHERE sleep)::BIGINT AS \"sleep!\",\n COUNT(*) FILTER (WHERE cache)::BIGINT AS \"cache!\",\n COUNT(*) FILTER (WHERE mock)::BIGINT AS \"mock!\",\n COUNT(*) FILTER (WHERE suspend)::BIGINT AS \"suspend!\",\n COUNT(*) FILTER (WHERE retry)::BIGINT AS \"retry!\",\n COUNT(*) FILTER (WHERE timeout)::BIGINT AS \"timeout!\",\n COUNT(*) FILTER (WHERE priority)::BIGINT AS \"priority!\",\n COUNT(*) FILTER (WHERE debounce)::BIGINT AS \"debounce!\",\n COUNT(*) FILTER (WHERE lifetime)::BIGINT AS \"lifetime!\",\n COUNT(*) FILTER (WHERE continue_on_error)::BIGINT AS \"continue_on_error!\",\n COUNT(*) FILTER (WHERE early_stop)::BIGINT AS \"early_stop!\",\n COUNT(*) FILTER (WHERE skip)::BIGINT AS \"skip!\"\n FROM scanned\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "flows_scanned!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "forloopflow!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "whileloopflow!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "branchall!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "branchall_sequential!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "branchone!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "aiagent!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "subflow!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "identity!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "trigger_step!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "squash!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "parallel_loop!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "keep_failures!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "parallelism!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "sleep!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "cache!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "mock!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "suspend!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 18,
|
||||
"name": "retry!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "timeout!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "priority!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "debounce!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "lifetime!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "continue_on_error!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "early_stop!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "skip!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e24252d48a1fcca73f20d62f37c9d7dc2071580be6d604979c95b3374cd4ad77"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET\n auto_invite = jsonb_set(\n jsonb_set(\n COALESCE(auto_invite, '{}'::jsonb),\n '{instance_groups}',\n (SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb)\n FROM jsonb_array_elements(COALESCE(auto_invite->'instance_groups', '[]'::jsonb)) elem\n WHERE elem #>> '{}' <> ALL($1))\n ),\n '{instance_groups_roles}',\n CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'\n THEN (auto_invite->'instance_groups_roles') - $1::text[]\n ELSE '{}'::jsonb\n END\n )\n WHERE auto_invite->'instance_groups' ?| $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e242c733ca0accb5287e80c97abe3bbae0638612bd111b6b6196dd2808d4b6d0"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT name FROM instance_group WHERE name = $1",
|
||||
"query": "SELECT name FROM instance_group WHERE id = $1 FOR UPDATE",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "565db14b889f69dfbda5db400a33223fd20548fb106d2a5e5669c0e23ecaa0eb"
|
||||
"hash": "e4e621724d830b318c06734683c8a48b0b692f7e390f2e95162d39ff3f6aeba7"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO trigger_history\n (workspace_id, trigger_kind, path, operation, source, username, changes)\n VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e52a80386b132e53a956458e2eb77d29bd6da4fc8511ed44f21d49f4c0965d36"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT kind::text AS \"kind!\", COUNT(*)::BIGINT AS \"count!\"\n FROM script WHERE archived = false AND deleted = false GROUP BY kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e63e275a158040659c41ec8d1ef9107558b0003f495ba3f5fba63b77616d12c8"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT email_to_igroup.email\n FROM email_to_igroup\n INNER JOIN instance_group ON instance_group.name = email_to_igroup.igroup\n WHERE instance_group.name = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ee182378d9a760c3593b483703ec1682488ca9e6e402ec41abaa6ae3ca263854"
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, trigger_kind, path, operation, source, username, created_at, changes\n FROM trigger_history\n WHERE workspace_id = $1\n AND ($2::TEXT IS NULL OR trigger_kind = $2)\n AND ($3::TEXT IS NULL OR path = $3)\n AND ( $6\n OR path = ANY($7)\n OR EXISTS ( SELECT 1 FROM unnest($8::text[]) AS pfx\n WHERE path = pfx\n OR left(path, length(pfx) + 1) = pfx || '/' ) )\n ORDER BY id DESC\n LIMIT $4 OFFSET $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "trigger_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operation",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "changes",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Bool",
|
||||
"TextArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fcbbc3b697249c6ee0ca542ea42fadecddead568110dd531fbace439c2263e4f"
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "fed842c14aa37998da2b3cfafc71f7364132ea1e40e687aa84c3d02399e3bfb5"
|
||||
}
|
||||
Generated
+146
-146
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -88,7 +88,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
a65162b22b127b54c0686095ee1b16b04e3111f7
|
||||
bd4de74eb37b32a2b6c7c69f6dedac031ef8436b
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Restore the instance_group source for members the up migration converted. The stripped
|
||||
-- auto_invite references cannot be restored (the groups they named no longer exist).
|
||||
UPDATE usr
|
||||
SET added_via = jsonb_build_object(
|
||||
'source', 'instance_group',
|
||||
'group', added_via->>'migrated_from_instance_group'
|
||||
)
|
||||
WHERE added_via->>'source' = 'manual'
|
||||
AND added_via ? 'migrated_from_instance_group';
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Strip auto_invite references to groups that no longer exist, so a later group created
|
||||
-- with the same name cannot silently re-acquire the mapping.
|
||||
UPDATE workspace_settings
|
||||
SET auto_invite = jsonb_set(
|
||||
jsonb_set(
|
||||
auto_invite,
|
||||
'{instance_groups}',
|
||||
COALESCE(
|
||||
(SELECT jsonb_agg(elem)
|
||||
FROM jsonb_array_elements(auto_invite->'instance_groups') elem
|
||||
WHERE EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = elem #>> '{}')),
|
||||
'[]'::jsonb
|
||||
)
|
||||
),
|
||||
'{instance_groups_roles}',
|
||||
CASE WHEN jsonb_typeof(auto_invite->'instance_groups_roles') = 'object'
|
||||
THEN (SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb)
|
||||
FROM jsonb_each(auto_invite->'instance_groups_roles')
|
||||
WHERE EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = key))
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
)
|
||||
WHERE jsonb_typeof(auto_invite->'instance_groups') = 'array'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(auto_invite->'instance_groups') elem
|
||||
WHERE NOT EXISTS (SELECT 1 FROM instance_group ig WHERE ig.name = elem #>> '{}')
|
||||
);
|
||||
|
||||
-- Workspace members whose instance-group grant can no longer be re-derived become manual
|
||||
-- members. Group deletion, overwrite imports and some SCIM paths used to mutate groups
|
||||
-- without carrying the change through to workspace membership, leaving members whose
|
||||
-- granting group was deleted — or who were dropped from a group that still exists. Under
|
||||
-- state-based reconciliation those members belong to zero configured groups, so the first
|
||||
-- reconcile touching their workspace would otherwise remove them and destroy their drafts,
|
||||
-- favorites, tokens and permissions. The original group name is kept under
|
||||
-- 'migrated_from_instance_group' so admins can identify and prune them deliberately.
|
||||
UPDATE usr
|
||||
SET added_via = jsonb_build_object(
|
||||
'source', 'manual',
|
||||
'migrated_from_instance_group', added_via->>'group'
|
||||
)
|
||||
WHERE added_via->>'source' = 'instance_group'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM workspace_settings ws
|
||||
JOIN LATERAL jsonb_array_elements_text(
|
||||
CASE WHEN jsonb_typeof(ws.auto_invite->'instance_groups') = 'array'
|
||||
THEN ws.auto_invite->'instance_groups'
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) g ON true
|
||||
JOIN email_to_igroup e ON e.igroup = g.value AND e.email = usr.email
|
||||
WHERE ws.workspace_id = usr.workspace_id
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS trigger_history;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Append-only record of every schedule/trigger mutation: who, what changed, and
|
||||
-- from which kind of client.
|
||||
CREATE TABLE IF NOT EXISTS trigger_history (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
-- 'schedule' or a trigger's TRIGGER_TYPE ('http', 'kafka', ...). Not the
|
||||
-- TRIGGER_KIND enum: that one is capture-oriented and misses 'schedule'.
|
||||
trigger_kind VARCHAR(50) NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
-- 'create' | 'update' | 'delete' | 'enable' | 'disable' | 'suspend'
|
||||
operation VARCHAR(20) NOT NULL,
|
||||
-- 'ui' | 'cli' | 'api' | 'worker'
|
||||
source VARCHAR(20) NOT NULL,
|
||||
-- NULL when the server acted on its own (worker auto-disable).
|
||||
username VARCHAR(255),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
-- {field: {old, new}} for the fields that actually changed. `old` is
|
||||
-- absent where it is not known: a create, and the workspace-wide handler
|
||||
-- override that rewrites every schedule without reading them first. NULL
|
||||
-- when the operation carries no field-level diff at all (delete).
|
||||
changes JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trigger_history_workspace_kind_path
|
||||
ON trigger_history(workspace_id, trigger_kind, path, id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trigger_history_workspace_id
|
||||
ON trigger_history(workspace_id, id DESC);
|
||||
|
||||
GRANT ALL ON TABLE trigger_history TO windmill_user;
|
||||
GRANT ALL ON TABLE trigger_history TO windmill_admin;
|
||||
GRANT ALL ON SEQUENCE trigger_history_id_seq TO windmill_user;
|
||||
GRANT ALL ON SEQUENCE trigger_history_id_seq TO windmill_admin;
|
||||
|
||||
ALTER TABLE trigger_history ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY admin_all ON trigger_history FOR ALL TO windmill_admin USING (true) WITH CHECK (true);
|
||||
|
||||
-- Every mutating trigger route writes through the RLS pool, so windmill_user
|
||||
-- must be able to append.
|
||||
CREATE POLICY allow_insert ON trigger_history FOR INSERT TO windmill_user WITH CHECK (true);
|
||||
|
||||
-- Reads mirror the path half of the live trigger's own policies: a row can
|
||||
-- quote a schedule's `args`, so it must not be readable by anyone who could not
|
||||
-- read the trigger it describes. Deliberately narrower than the live row on one
|
||||
-- point — the `extra_perms` grants have no counterpart here, since the history
|
||||
-- does not carry the row's ACL and must survive its deletion. Narrower is the
|
||||
-- safe direction.
|
||||
CREATE POLICY see_own ON trigger_history FOR SELECT TO windmill_user
|
||||
USING (
|
||||
SPLIT_PART(path::text, '/', 1) = 'u'
|
||||
AND SPLIT_PART(path::text, '/', 2) = current_setting('session.user')
|
||||
);
|
||||
|
||||
CREATE POLICY see_member ON trigger_history FOR SELECT TO windmill_user
|
||||
USING (
|
||||
SPLIT_PART(path::text, '/', 1) = 'g'
|
||||
AND SPLIT_PART(path::text, '/', 2) = ANY(regexp_split_to_array(current_setting('session.groups'), ','))
|
||||
);
|
||||
|
||||
CREATE POLICY see_folder_extra_perms_user ON trigger_history FOR SELECT TO windmill_user
|
||||
USING (
|
||||
SPLIT_PART(path::text, '/', 1) = 'f'
|
||||
AND SPLIT_PART(path::text, '/', 2) = ANY(regexp_split_to_array(current_setting('session.folders_read'), ','))
|
||||
);
|
||||
@@ -159,7 +159,8 @@
|
||||
"sage_intacct": {
|
||||
"auth_url": "https://api.intacct.com/ia/api/v1/oauth2/authorize",
|
||||
"token_url": "https://api.intacct.com/ia/api/v1/oauth2/token",
|
||||
"scopes": ["offline_access"]
|
||||
"scopes": ["offline_access"],
|
||||
"req_body_auth": true
|
||||
},
|
||||
"spotify": {
|
||||
"auth_url": "https://accounts.spotify.com/authorize",
|
||||
|
||||
+24
-24
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6274,7 +6274,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6286,7 +6286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6295,7 +6295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6307,7 +6307,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6319,7 +6319,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6331,7 +6331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6343,7 +6343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6355,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6366,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6377,7 +6377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6389,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6400,7 +6400,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6422,7 +6422,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6434,7 +6434,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6448,7 +6448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6465,7 +6465,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6478,7 +6478,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6490,7 +6490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6508,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6524,7 +6524,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6540,7 +6540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6572,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6586,7 +6586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.789.0"
|
||||
version = "1.792.2"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+46
-18
@@ -1629,6 +1629,10 @@ Windmill Community Edition {GIT_VERSION}
|
||||
}
|
||||
}
|
||||
|
||||
// `workers_f` must stay ahead of `server_f`: these are polled on one task in
|
||||
// declaration order, and `run_server` yields once after handing over the base
|
||||
// internal url so the workers get past that oneshot before it builds its router.
|
||||
// Ordering `server_f` first makes them wait out the whole build instead.
|
||||
if mcp_mode {
|
||||
futures::try_join!(workers_f, server_f)?;
|
||||
} else {
|
||||
@@ -1691,7 +1695,8 @@ async fn process_notify_event(
|
||||
"restart_worker_group" => {
|
||||
if worker_mode && payload == *WORKER_GROUP {
|
||||
tracing::info!("Restart requested for worker group '{payload}'");
|
||||
spawn_graceful_killpill(tx, db, 30, "worker group restart requested").await;
|
||||
spawn_graceful_killpill(tx, db, 30, "worker group restart requested", server_mode)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
"notify_webhook_change" => {
|
||||
@@ -1997,8 +2002,14 @@ async fn process_notify_event(
|
||||
reload_otel_tracing_proxy_setting(conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
spawn_graceful_killpill(tx, db, 30, "OTEL tracing proxy setting change")
|
||||
.await;
|
||||
spawn_graceful_killpill(
|
||||
tx,
|
||||
db,
|
||||
30,
|
||||
"OTEL tracing proxy setting change",
|
||||
server_mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
@@ -2009,12 +2020,20 @@ async fn process_notify_event(
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
spawn_graceful_killpill(tx, db, 30, "metrics setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
|
||||
.await;
|
||||
}
|
||||
EMAIL_DOMAIN_SETTING => {
|
||||
tracing::info!("Email domain setting changed");
|
||||
if server_mode {
|
||||
spawn_graceful_killpill(tx, db, 30, "email domain setting change").await;
|
||||
spawn_graceful_killpill(
|
||||
tx,
|
||||
db,
|
||||
30,
|
||||
"email domain setting change",
|
||||
server_mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EXPOSE_DEBUG_METRICS_SETTING => {
|
||||
@@ -2050,19 +2069,26 @@ async fn process_notify_event(
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
spawn_graceful_killpill(tx, db, 30, "OTEL setting change").await;
|
||||
spawn_graceful_killpill(tx, db, 30, "OTEL setting change", server_mode).await;
|
||||
}
|
||||
REQUEST_SIZE_LIMIT_SETTING => {
|
||||
if server_mode {
|
||||
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
|
||||
spawn_graceful_killpill(tx, db, 30, "request size limit change").await;
|
||||
spawn_graceful_killpill(
|
||||
tx,
|
||||
db,
|
||||
30,
|
||||
"request size limit change",
|
||||
server_mode,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
SAML_METADATA_SETTING => {
|
||||
tracing::info!(
|
||||
"SAML metadata change detected, killing server expecting to be restarted"
|
||||
);
|
||||
spawn_graceful_killpill(tx, db, 30, "SAML metadata change").await;
|
||||
spawn_graceful_killpill(tx, db, 30, "SAML metadata change", server_mode).await;
|
||||
}
|
||||
HUB_BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
|
||||
@@ -2183,12 +2209,7 @@ pub async fn run_workers(
|
||||
// #[cfg(tokio_unstable)]
|
||||
// let monitor = tokio_metrics::TaskMonitor::new();
|
||||
|
||||
let ip = windmill_common::external_ip::get_ip()
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = e.to_string(), "failed to get external IP");
|
||||
"unretrievable IP".to_string()
|
||||
});
|
||||
windmill_common::external_ip::resolve_ip_in_background();
|
||||
|
||||
let mut handles = Vec::with_capacity(num_workers as usize);
|
||||
|
||||
@@ -2232,7 +2253,6 @@ pub async fn run_workers(
|
||||
let conn1 = wk_conf.conn.clone();
|
||||
let worker_name = wk_conf.worker_name.clone();
|
||||
WORKERS_NAMES.write().await.push(worker_name.clone());
|
||||
let ip = ip.clone();
|
||||
let rx = killpill_rxs.pop().unwrap();
|
||||
let tx = tx.clone();
|
||||
let base_internal_url = base_internal_url.clone();
|
||||
@@ -2249,7 +2269,6 @@ pub async fn run_workers(
|
||||
worker_name,
|
||||
i as u64,
|
||||
num_workers as u32,
|
||||
&ip,
|
||||
rx,
|
||||
tx,
|
||||
&base_internal_url,
|
||||
@@ -2286,16 +2305,24 @@ pub async fn run_workers(
|
||||
/// then the sleep+kill is spawned in the background so the notification handler is not blocked.
|
||||
///
|
||||
/// Falls back to drain-only delay if DB coordination fails.
|
||||
///
|
||||
/// Only `server_mode` processes coordinate, on the strength of the worker case: a worker
|
||||
/// group restarting costs queue latency rather than lost work, `v2_job_queue` being durable.
|
||||
/// Were workers to take part, one could claim the `is_first` slot and leave every server
|
||||
/// holding its shutdown open for a peer that serves no API traffic.
|
||||
async fn spawn_graceful_killpill(
|
||||
tx: &KillpillSender,
|
||||
db: &Pool<Postgres>,
|
||||
safety_margin_secs: u64,
|
||||
context: &str,
|
||||
server_mode: bool,
|
||||
) {
|
||||
// Minimum delay before any restart to let in-flight requests drain
|
||||
const DRAIN_DELAY_SECS: u64 = 3;
|
||||
|
||||
let (delay, is_first) =
|
||||
let (delay, is_first) = if !server_mode {
|
||||
(DRAIN_DELAY_SECS, true)
|
||||
} else {
|
||||
match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -2305,7 +2332,8 @@ async fn spawn_graceful_killpill(
|
||||
);
|
||||
(DRAIN_DELAY_SECS, true)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"Scheduling {context} graceful shutdown in {delay}s (first_to_restart={is_first})"
|
||||
|
||||
+1052
-420
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,9 @@ token: token_hash(char), token_prefix(char), token(char), label(char), expiratio
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token_expiry_notification: token_hash(char), expiration(ts)
|
||||
INDEX: idx_token_expiry_notification_expiration (expiration)
|
||||
trigger_history: id(bigint), workspace_id(char), trigger_kind(char), path(char), operation(char), source(char), username(char), created_at(ts), changes(jsonb)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
INDEX: idx_trigger_history_workspace_kind_path (workspace_id, trigger_kind, path, id), idx_trigger_history_workspace_id (workspace_id, id)
|
||||
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
|
||||
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts), email(text), username(text), is_admin(bool), is_operator(bool), workspace_id(text?), label(text?), scopes(text[]?)
|
||||
usage: id(char), is_workspace(bool), month_(int), usage(int)
|
||||
|
||||
+70
@@ -19,6 +19,76 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc
|
||||
ARRAY['jobs:read', 'if_jobs:filter_tags:deno']
|
||||
);
|
||||
|
||||
-- A path-scoped run token for test-user-2, as the trigger UI mints per runnable for a
|
||||
-- webhook caller. test-user-2 created every job this token is asserted against, so the
|
||||
-- `created_by` grant would otherwise hand it all of them; it must reach only jobs of
|
||||
-- `f/shared/flow1`.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('RUN_SCOPED_TOKEN'::bytea), 'hex'), 'RUN_SCOPE', 'RUN_SCOPED_TOKEN',
|
||||
'test2@windmill.dev', 'flow webhook token', false,
|
||||
ARRAY['jobs:run:flows:f/shared/flow1']
|
||||
);
|
||||
|
||||
-- Same, scoped to a script. The two jobs below both run through a `singlestepflow`
|
||||
-- wrapper (native retry / scheduled runs produce these) — one wrapping a script, one
|
||||
-- wrapping a flow — so the confinement has to project each onto the runnable it wraps
|
||||
-- rather than onto the wrapper's own `kind`.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('RUN_SCOPED_SCRIPT_TOKEN'::bytea), 'hex'), 'RUN_SCRIP', 'RUN_SCOPED_SCRIPT_TOKEN',
|
||||
'test2@windmill.dev', 'script webhook token', false,
|
||||
ARRAY['jobs:run:scripts:u/test-user-2/wrapped_script']
|
||||
);
|
||||
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow
|
||||
) VALUES (
|
||||
'14141414-1414-1414-1414-141414141414', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'singlestepflow', 'deno', 'u/test-user-2/wrapped_script', 'deno', true,
|
||||
'{"modules": [{"id": "a", "value": {"type": "script", "path": "u/test-user-2/wrapped_script"}}]}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('14141414-1414-1414-1414-141414141414', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"wrapped": "WRAPPED_RESULT"}');
|
||||
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow
|
||||
) VALUES (
|
||||
'15151515-1515-1515-1515-151515151515', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'singlestepflow', 'deno', 'f/shared/flow1', 'flow', true,
|
||||
'{"modules": [{"id": "a", "value": {"type": "flow", "path": "f/shared/flow1"}}]}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"wrapped": "WRAPPED_FLOW_RESULT"}');
|
||||
|
||||
-- A token pairing an app scope with a run scope, as someone driving an app's components
|
||||
-- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no
|
||||
-- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('APP_RUNNER_TOKEN'::bytea), 'hex'), 'APP_RUNNE', 'APP_RUNNER_TOKEN',
|
||||
'test2@windmill.dev', 'app runner token', false,
|
||||
ARRAY['apps:run:u/test-user-2/dash', 'jobs:run:scripts:u/test-user-2/wrapped_script']
|
||||
);
|
||||
|
||||
-- An inline-script component run of app `u/test-user-2/dash`, stamped with the
|
||||
-- app provenance `execute_component` sets (`trigger_kind = 'app'`).
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, visible_to_owner, trigger_kind, trigger, args
|
||||
) VALUES (
|
||||
'16161616-1616-1616-1616-161616161616', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'appscript', 'deno', NULL, 'deno', false, 'app', 'u/test-user-2/dash',
|
||||
'{"component": "arg"}'
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('16161616-1616-1616-1616-161616161616', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"inline": "APP_INLINE_RESULT"}');
|
||||
|
||||
-- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed
|
||||
-- low-code app token: carries the `app_embed` sentinel plus the embed scope set.
|
||||
-- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
def greet(name: str) -> str:
|
||||
# Verify that __file__ is set on this module (same check typeguard does)
|
||||
mod = sys.modules[__name__]
|
||||
source_file = inspect.getfile(mod)
|
||||
return f"Hello, {name}! from {source_file}"
|
||||
|
||||
def main():
|
||||
return greet("World")
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/system/typechecked_helper', 12349, 'python3', '');
|
||||
@@ -46,6 +46,12 @@ const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777";
|
||||
const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212";
|
||||
// A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it.
|
||||
const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313";
|
||||
// `singlestepflow` wrappers (as native retry / scheduled runs produce), one around a
|
||||
// SCRIPT and one around a FLOW.
|
||||
const WRAPPED_JOB: &str = "14141414-1414-1414-1414-141414141414";
|
||||
const WRAPPED_FLOW_JOB: &str = "15151515-1515-1515-1515-151515151515";
|
||||
// An inline-script component run of app `u/test-user-2/dash` (`trigger_kind = 'app'`).
|
||||
const APP_INLINE_JOB: &str = "16161616-1616-1616-1616-161616161616";
|
||||
// Queued sub-flow test-user-3 can see (folder `shared`), whose parent top flow they
|
||||
// cannot. Force cancel walks up to that parent.
|
||||
const QUEUED_VISIBLE_MID: &str = "55555555-5555-5555-5555-555555555555";
|
||||
@@ -380,6 +386,160 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PATH-SCOPED RUN TOKEN: confined to jobs of the runnable it may start.
|
||||
// RUN_SCOPED_TOKEN is test-user-2's `jobs:run:flows:f/shared/flow1` webhook
|
||||
// token, and test-user-2 created every job asserted on below — so `created_by`
|
||||
// alone would hand it all of them.
|
||||
// Its own flow run reads, and so do the steps beneath it: a step's `runnable_path`
|
||||
// is the inner script's, so the scope has to be satisfied through the ancestor.
|
||||
for (path, expected) in [
|
||||
(
|
||||
format!("completed/get_result/{FLOW_JOB}"),
|
||||
r#""flow": "done""#,
|
||||
),
|
||||
(
|
||||
format!("completed/get_result/{STEP_JOB}"),
|
||||
"STEP_RESULT_INHERITED",
|
||||
),
|
||||
] {
|
||||
let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"run-scoped token must read its own flow run ({path}, got {status}): {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(expected),
|
||||
"run-scoped token should get {expected} for {path}: {body}"
|
||||
);
|
||||
}
|
||||
// A job of any other runnable is out of scope, even though the same user created it.
|
||||
for path in [
|
||||
format!("completed/get_result/{VICTIM}"),
|
||||
format!("get_args/{VICTIM}"),
|
||||
format!("get_logs/{VICTIM}"),
|
||||
format!("getupdate/{VICTIM}?only_result=true"),
|
||||
] {
|
||||
let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"run-scoped token must not read a job outside its scope ({path}, got {status}): {body}"
|
||||
);
|
||||
for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] {
|
||||
assert!(
|
||||
!body.contains(secret),
|
||||
"run-scoped token response for {path} leaked `{secret}`: {body}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// A `singlestepflow` wrapper (native retry / scheduled run) belongs to the runnable
|
||||
// it wraps, not to the flow domain its `kind` suggests. Each wrapper is readable by
|
||||
// the token scoped to the wrapped kind, and only by that one.
|
||||
for (job, reader, denied) in [
|
||||
(WRAPPED_JOB, "RUN_SCOPED_SCRIPT_TOKEN", "RUN_SCOPED_TOKEN"),
|
||||
(
|
||||
WRAPPED_FLOW_JOB,
|
||||
"RUN_SCOPED_TOKEN",
|
||||
"RUN_SCOPED_SCRIPT_TOKEN",
|
||||
),
|
||||
] {
|
||||
let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(reader)).await;
|
||||
assert!(
|
||||
status.is_success() && body.contains("WRAPPED"),
|
||||
"{reader} must read the singlestepflow wrapping its runnable (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(denied)).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"{denied} must not read a wrapper around the other kind (got {status}): {body}"
|
||||
);
|
||||
}
|
||||
|
||||
// An `apps:run:<app>` scope is a start grant too: the inline-script component run it
|
||||
// launched — a kind no `jobs:run` scope can name — stays readable to a token scoped
|
||||
// to that app, and stays out of reach for one that is only scoped to run jobs.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{APP_INLINE_JOB}"),
|
||||
Some("APP_RUNNER_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success() && body.contains("APP_INLINE_RESULT"),
|
||||
"app-scoped token must read the component run its app launched (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{APP_INLINE_JOB}"),
|
||||
Some("RUN_SCOPED_SCRIPT_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a token with no scope on the app must not read its component run (got {status}): {body}"
|
||||
);
|
||||
|
||||
// An approval link is a bypass of the read gate, so the confinement is re-applied on
|
||||
// top of it: it must not become a way for a scoped token to read an out-of-scope job.
|
||||
// The link itself is untouched — a logged-out approver still reads the same job.
|
||||
let approval_token =
|
||||
windmill_common::variables::generate_approval_token("test-workspace", VICTIM.parse()?, &db)
|
||||
.await?;
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("get/{VICTIM}?approval_token={approval_token}"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"an approval link must still authorize a logged-out read (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("get/{VICTIM}?approval_token={approval_token}"),
|
||||
Some("RUN_SCOPED_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"an approval link must not lift the run-scope confinement (got {status}): {body}"
|
||||
);
|
||||
|
||||
// Same for the resume-secret bypass on the result route, which the approval page uses.
|
||||
let (status, secret) = get(
|
||||
&authed_base,
|
||||
&format!("job_signature/{STEP_JOB}/0"),
|
||||
Some("SECRET_TOKEN_2"),
|
||||
)
|
||||
.await;
|
||||
assert!(status.is_success(), "owner must mint a resume secret: {secret}");
|
||||
let secret = secret.trim().trim_matches('"').to_string();
|
||||
let approval_result =
|
||||
format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}");
|
||||
let (status, body) = get(&base, &approval_result, None).await;
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"a resume secret must still authorize a logged-out result read (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(&base, &approval_result, Some("RUN_SCOPED_SCRIPT_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a resume secret must not lift the run-scope confinement (got {status}): {body}"
|
||||
);
|
||||
|
||||
// And a run grant is not an enumeration grant: the whole listing surface is denied.
|
||||
let (status, body) = get(&authed_base, "list", Some("RUN_SCOPED_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::FORBIDDEN,
|
||||
"run-scoped token must not enumerate jobs (got {status}): {body}"
|
||||
);
|
||||
|
||||
// ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token
|
||||
// may cancel a job it launched (created_by == viewer), but `cancel_job_api`
|
||||
// denies (NotFound) a job created by someone else, even one the (admin)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Pins the `parent_hash` filter of `GET /w/{workspace}/scripts/list`: the
|
||||
//! request must succeed and return exactly the scripts whose `parent_hashes`
|
||||
//! array contains the given hash. The filter is assembled into a raw SQL string,
|
||||
//! so a malformed predicate is only caught by executing the query.
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", format!("Bearer {}", token))
|
||||
}
|
||||
|
||||
fn new_script(path: &str, content: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"path": path,
|
||||
"summary": "",
|
||||
"description": "",
|
||||
"content": content,
|
||||
"language": "deno",
|
||||
"schema": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(port: u16, script: serde_json::Value) -> anyhow::Result<String> {
|
||||
let resp = authed(
|
||||
client().post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.json(&script)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 201, "script create should succeed: {body}");
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// Paths returned by `scripts/list` for the given query string.
|
||||
async fn list_paths(port: u16, query: &str) -> anyhow::Result<Vec<String>> {
|
||||
let resp = authed(
|
||||
client().get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/list?{query}"
|
||||
)),
|
||||
"SECRET_TOKEN",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
assert_eq!(status, 200, "scripts/list?{query} should succeed: {body}");
|
||||
let items: Vec<serde_json::Value> = serde_json::from_str(&body)?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.map(|it| it["path"].as_str().unwrap().to_string())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_scripts_parent_hash_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let lineage_path = "u/test-user/parent_hash_lineage";
|
||||
let unrelated_path = "u/test-user/parent_hash_unrelated";
|
||||
|
||||
// A two-version lineage: v2's `parent_hashes` contains v1's hash.
|
||||
let v1 = create(
|
||||
port,
|
||||
new_script(lineage_path, "export async function main() { return 1; }"),
|
||||
)
|
||||
.await?;
|
||||
let mut v2_body = new_script(lineage_path, "export async function main() { return 2; }");
|
||||
v2_body["parent_hash"] = json!(v1);
|
||||
let v2 = create(port, v2_body).await?;
|
||||
|
||||
// ...plus a script with no lineage at all, which must never match.
|
||||
create(
|
||||
port,
|
||||
new_script(unrelated_path, "export async function main() { return 3; }"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let all = list_paths(port, "").await?;
|
||||
assert!(
|
||||
all.contains(&lineage_path.to_string()) && all.contains(&unrelated_path.to_string()),
|
||||
"unfiltered listing should return both scripts, got {all:?}"
|
||||
);
|
||||
|
||||
let descendants = list_paths(port, &format!("parent_hash={v1}")).await?;
|
||||
assert_eq!(
|
||||
descendants,
|
||||
vec![lineage_path.to_string()],
|
||||
"parent_hash should return only the scripts descending from that hash"
|
||||
);
|
||||
|
||||
// v2 is the head, so nothing descends from it.
|
||||
let none = list_paths(port, &format!("parent_hash={v2}")).await?;
|
||||
assert!(
|
||||
none.is_empty(),
|
||||
"no script descends from the head version, got {none:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -19,6 +19,11 @@
|
||||
//! and the request only fails later at the connect/SSRF step — proving the
|
||||
//! legitimate path still resolves the token (no over-blocking).
|
||||
//!
|
||||
//! `POST .../resources/mcp_call_tool/{path}` reaches the same MCP server through
|
||||
//! the same resource, so it is pinned to the same property here — both handlers
|
||||
//! share `connect_mcp_client`, and a future split of that helper must not let
|
||||
//! one of them regress.
|
||||
//!
|
||||
//! SSRF rejection of an author-controlled URL is covered by the unit test in
|
||||
//! `windmill-mcp` (`from_resource_rejects_ssrf_url`).
|
||||
#![cfg(feature = "mcp")]
|
||||
@@ -27,6 +32,7 @@ use sqlx::{Pool, Postgres};
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const SECRET_VALUE: &str = "S3CRET-MCP-TOKEN-VALUE";
|
||||
const RESOURCE_PATH: &str = "u/test-user-3/evil_mcp";
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
@@ -44,13 +50,28 @@ async fn get(base: &str, path: &str, token: &str) -> (reqwest::StatusCode, Strin
|
||||
(status, body)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
|
||||
async fn test_mcp_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
async fn post(
|
||||
base: &str,
|
||||
path: &str,
|
||||
token: &str,
|
||||
body: serde_json::Value,
|
||||
) -> (reqwest::StatusCode, String) {
|
||||
let resp = client()
|
||||
.post(format!("{base}/{path}"))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.expect("request");
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.expect("body");
|
||||
(status, body)
|
||||
}
|
||||
|
||||
// Insert the locked secret variable with a real, workspace-key-encrypted
|
||||
// value so an authorized read genuinely decrypts it.
|
||||
let mc = windmill_common::variables::build_crypt(&db, "test-workspace").await?;
|
||||
/// Insert the locked secret variable with a real, workspace-key-encrypted value
|
||||
/// so an authorized read genuinely decrypts it.
|
||||
async fn insert_locked_secret(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
||||
let mc = windmill_common::variables::build_crypt(db, "test-workspace").await?;
|
||||
let encrypted = windmill_common::variables::encrypt(&mc, SECRET_VALUE);
|
||||
// Runtime-checked query (not the `query!` macro) so no offline `.sqlx` cache
|
||||
// entry is needed for this test-only insert.
|
||||
@@ -59,13 +80,21 @@ async fn test_mcp_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
VALUES ('test-workspace', 'f/locked/secret_token', $1, true, 'Locked secret', '{}')",
|
||||
)
|
||||
.bind(&encrypted)
|
||||
.execute(&db)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
|
||||
async fn test_mcp_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
insert_locked_secret(&db).await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources/mcp_tools");
|
||||
let path = "u/test-user-3/evil_mcp";
|
||||
let path = RESOURCE_PATH;
|
||||
|
||||
// ---- CORE REGRESSION: the developer can read the resource but must NOT be
|
||||
// able to resolve the locked secret. They are denied (401) at the
|
||||
@@ -109,3 +138,47 @@ async fn test_mcp_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "mcp_token_exfil"))]
|
||||
async fn test_mcp_call_tool_token_not_exfiltrated(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
insert_locked_secret(&db).await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources/mcp_call_tool");
|
||||
let body = serde_json::json!({ "tool": "whoami", "arguments": {} });
|
||||
|
||||
let (status, resp) = post(&base, RESOURCE_PATH, "SECRET_TOKEN_3", body.clone()).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
"developer must be denied resolving a secret they can't read (got {status}): {resp}"
|
||||
);
|
||||
assert!(
|
||||
!resp.contains(SECRET_VALUE),
|
||||
"the locked secret must never leak to the developer: {resp}"
|
||||
);
|
||||
assert!(
|
||||
resp.contains("don't have access"),
|
||||
"denial should come from the variable-RLS gate, not a connection error: {resp}"
|
||||
);
|
||||
assert!(
|
||||
!resp.contains("Failed to connect to MCP server"),
|
||||
"developer must be blocked before the connection step (would mean the token was resolved): {resp}"
|
||||
);
|
||||
|
||||
let (status, resp) = post(&base, RESOURCE_PATH, "SECRET_TOKEN", body).await;
|
||||
assert_ne!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
"admin must clear the variable-RLS gate (got {status}): {resp}"
|
||||
);
|
||||
assert!(
|
||||
resp.contains("Failed to connect to MCP server"),
|
||||
"admin should resolve the token and only fail at the connect/SSRF step: {resp}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -241,7 +241,6 @@ fn spawn_workers(
|
||||
worker_name,
|
||||
i as u64,
|
||||
n as u32,
|
||||
"127.0.0.1",
|
||||
rx,
|
||||
tx2,
|
||||
&base_internal_url,
|
||||
|
||||
@@ -1132,7 +1132,8 @@ async def main(item: str, qty: int, email: str):
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1240,6 +1241,50 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool<Postgres>) -> anyhow::Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base", "typechecked_python"))]
|
||||
async fn test_typechecked_decorator_python(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
from f.system.typechecked_helper import greet
|
||||
|
||||
def main():
|
||||
return greet("World")
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: Some("f/system/test_typechecked".to_string()),
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
tag: None,
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
let result_str = result.as_str().unwrap();
|
||||
assert!(
|
||||
result_str.starts_with("Hello, World! from "),
|
||||
"unexpected result: {result_str}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End-to-end comparison between the legacy `step()` suspend-and-replay path
|
||||
/// and the new SDK inline-persist fast path, toggled per-job via the
|
||||
/// `WM_WAC_INLINE_FAST_PATH` env var which the Python script sets on its own
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::{external_ip::UNKNOWN_IP, worker::insert_ping_query};
|
||||
|
||||
async fn insert_ping(db: &Pool<Postgres>, worker: &str, ip: Option<&str>) -> anyhow::Result<()> {
|
||||
insert_ping_query(
|
||||
"test-instance",
|
||||
worker,
|
||||
"default",
|
||||
ip,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
"test",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
db,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The external IP resolves in the background, so the initial ping often has none yet. That must
|
||||
/// not blank the address a previous process wrote to the row this one reclaims — worker names are
|
||||
/// stable across restarts under EXIT_AFTER_N_JOBS.
|
||||
#[sqlx::test]
|
||||
async fn unresolved_ip_keeps_the_reclaimed_rows_address(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
insert_ping(&db, "wk-reclaimed", Some("1.2.3.4")).await?;
|
||||
insert_ping(&db, "wk-reclaimed", None).await?;
|
||||
let ip: String = sqlx::query_scalar("SELECT ip FROM worker_ping WHERE worker = $1")
|
||||
.bind("wk-reclaimed")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(ip, "1.2.3.4");
|
||||
|
||||
insert_ping(&db, "wk-fresh", None).await?;
|
||||
let ip: String = sqlx::query_scalar("SELECT ip FROM worker_ping WHERE worker = $1")
|
||||
.bind("wk-fresh")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(ip, UNKNOWN_IP);
|
||||
Ok(())
|
||||
}
|
||||
@@ -109,6 +109,19 @@ const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS: &[&str] = &[
|
||||
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
];
|
||||
|
||||
/// Claude 4.6 and later are published under several id spellings for the same
|
||||
/// model (`anthropic.claude-sonnet-4-6`, `...-4-6-v1`, `...-4-6-v1:0`), so they
|
||||
/// are matched by family prefix rather than by exact id.
|
||||
const BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES: &[&str] = &[
|
||||
"anthropic.claude-fable-5",
|
||||
"anthropic.claude-opus-4-6",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-5",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-5",
|
||||
];
|
||||
|
||||
fn build_default_cache_point() -> aws_sdk_bedrockruntime::types::CachePointBlock {
|
||||
aws_sdk_bedrockruntime::types::CachePointBlock::builder()
|
||||
.r#type(aws_sdk_bedrockruntime::types::CachePointType::Default)
|
||||
@@ -123,7 +136,7 @@ fn normalize_bedrock_model_id(model: &str) -> String {
|
||||
.unwrap_or(model)
|
||||
.to_ascii_lowercase();
|
||||
|
||||
for prefix in ["global.", "us.", "eu.", "apac."] {
|
||||
for prefix in ["global.", "us.", "eu.", "apac.", "au."] {
|
||||
if let Some(normalized_model) = model.strip_prefix(prefix) {
|
||||
return normalized_model.to_string();
|
||||
}
|
||||
@@ -135,6 +148,9 @@ fn normalize_bedrock_model_id(model: &str) -> String {
|
||||
pub fn bedrock_model_supports_prompt_caching(model: &str) -> bool {
|
||||
let normalized_model = normalize_bedrock_model_id(model);
|
||||
BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_IDS.contains(&normalized_model.as_str())
|
||||
|| BEDROCK_PROMPT_CACHING_SUPPORTED_MODEL_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| normalized_model.starts_with(prefix))
|
||||
}
|
||||
|
||||
fn append_cache_point_to_system_prompts(system_prompts: &mut Vec<SystemContentBlock>) {
|
||||
@@ -1241,6 +1257,27 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// Claude 4.6+ ships under bare, `-v1` and `-v1:0` spellings of the same id,
|
||||
/// so every one of them has to reach the prefix match.
|
||||
#[test]
|
||||
fn bedrock_prompt_caching_supports_claude_4_6_and_later_id_spellings() {
|
||||
for model in [
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-4-6-v1:0",
|
||||
"us.anthropic.claude-opus-4-6-v1",
|
||||
"global.anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-5",
|
||||
"eu.anthropic.claude-sonnet-5-v1:0",
|
||||
"au.anthropic.claude-sonnet-5",
|
||||
"anthropic.claude-fable-5",
|
||||
] {
|
||||
assert!(
|
||||
bedrock_model_supports_prompt_caching(model),
|
||||
"{model} must support prompt caching"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_prompt_caching_rejects_unsupported_or_opaque_model_ids() {
|
||||
assert!(!bedrock_model_supports_prompt_caching(
|
||||
@@ -1249,5 +1286,9 @@ mod tests {
|
||||
assert!(!bedrock_model_supports_prompt_caching(
|
||||
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile"
|
||||
));
|
||||
// Opus 4.5 is dated-id only — the 4.6+ prefixes must not swallow it.
|
||||
assert!(!bedrock_model_supports_prompt_caching(
|
||||
"anthropic.claude-opus-4-5-20251101-v2:0"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
|
||||
use crate::{
|
||||
ai_google::parse_data_url,
|
||||
ai_providers::{AIPlatform, AIProvider},
|
||||
@@ -137,17 +138,23 @@ pub struct AnthropicMessage {
|
||||
pub content: Vec<AnthropicRequestContent>,
|
||||
}
|
||||
|
||||
/// Adaptive thinking config for Anthropic native API. `summarized` display
|
||||
/// matches the chat proxy path (renders a summarized thinking stream).
|
||||
/// Thinking config for the Anthropic native API. `summarized` display matches
|
||||
/// the chat proxy path (renders a summarized thinking stream); the disable
|
||||
/// carries no display.
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct AnthropicThinking {
|
||||
pub r#type: &'static str,
|
||||
pub display: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl AnthropicThinking {
|
||||
fn adaptive() -> Self {
|
||||
Self { r#type: "adaptive", display: "summarized" }
|
||||
Self { r#type: "adaptive", display: Some("summarized") }
|
||||
}
|
||||
|
||||
fn disabled() -> Self {
|
||||
Self { r#type: "disabled", display: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +164,37 @@ pub struct AnthropicOutputConfig {
|
||||
pub effort: String,
|
||||
}
|
||||
|
||||
/// Resolve the thinking config, effort and sampling params for a reasoning
|
||||
/// selection. Temperature is dropped both under adaptive thinking, which
|
||||
/// rejects it, and on the models that removed the sampling params outright.
|
||||
fn anthropic_thinking_config(
|
||||
model: &str,
|
||||
reasoning_effort: Option<&str>,
|
||||
temperature: Option<f32>,
|
||||
) -> (
|
||||
Option<AnthropicThinking>,
|
||||
Option<AnthropicOutputConfig>,
|
||||
Option<f32>,
|
||||
) {
|
||||
let temperature = (!anthropic_model_rejects_sampling_params(model))
|
||||
.then_some(temperature)
|
||||
.flatten();
|
||||
match reasoning_effort {
|
||||
// The disable sentinel is not an effort token — Anthropic's vocabulary
|
||||
// is low..max and rejects it. The disable carries no effort either:
|
||||
// pairing it with xhigh or max is itself a 400 on Opus 5.
|
||||
Some(effort) if effort == REASONING_OFF_SENTINEL => {
|
||||
(Some(AnthropicThinking::disabled()), None, temperature)
|
||||
}
|
||||
Some(effort) => (
|
||||
Some(AnthropicThinking::adaptive()),
|
||||
Some(AnthropicOutputConfig { effort: effort.to_string() }),
|
||||
None,
|
||||
),
|
||||
None => (None, None, temperature),
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic-specific request structure for standard API
|
||||
#[derive(Serialize)]
|
||||
pub struct AnthropicRequest<'a> {
|
||||
@@ -652,16 +690,8 @@ impl AnthropicQueryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive thinking rejects sampling params, so drop temperature when
|
||||
// reasoning is on (Anthropic returns a hard 400 otherwise).
|
||||
let (thinking, output_config, temperature) = match args.reasoning_effort {
|
||||
Some(effort) => (
|
||||
Some(AnthropicThinking::adaptive()),
|
||||
Some(AnthropicOutputConfig { effort: effort.to_string() }),
|
||||
None,
|
||||
),
|
||||
None => (None, None, args.temperature),
|
||||
};
|
||||
let (thinking, output_config, temperature) =
|
||||
anthropic_thinking_config(args.model, args.reasoning_effort, args.temperature);
|
||||
|
||||
// Build request based on platform
|
||||
if self.is_vertex() {
|
||||
@@ -1096,6 +1126,83 @@ mod tests {
|
||||
assert!(body.get("temperature").is_none());
|
||||
}
|
||||
|
||||
/// An agent step stores the chat's off sentinel verbatim as its
|
||||
/// `reasoning_effort`, so the disable has to be translated here rather than
|
||||
/// forwarded as an effort token Anthropic would reject.
|
||||
#[test]
|
||||
fn anthropic_thinking_config_translates_the_off_sentinel() {
|
||||
let (thinking, output_config, _) =
|
||||
anthropic_thinking_config("claude-sonnet-4-6", Some("none"), Some(0.5));
|
||||
assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("disabled"));
|
||||
assert!(output_config.is_none());
|
||||
|
||||
let (thinking, output_config, temperature) =
|
||||
anthropic_thinking_config("claude-sonnet-4-6", Some("xhigh"), Some(0.5));
|
||||
assert_eq!(thinking.as_ref().map(|t| t.r#type), Some("adaptive"));
|
||||
assert_eq!(output_config.map(|c| c.effort), Some("xhigh".to_string()));
|
||||
// Adaptive thinking rejects sampling params on every model.
|
||||
assert!(temperature.is_none());
|
||||
|
||||
let (thinking, output_config, temperature) =
|
||||
anthropic_thinking_config("claude-sonnet-4-6", None, Some(0.5));
|
||||
assert!(thinking.is_none());
|
||||
assert!(output_config.is_none());
|
||||
assert_eq!(temperature, Some(0.5));
|
||||
}
|
||||
|
||||
/// Live-verified: Opus 4.8 and the 5 family 400 with `temperature is
|
||||
/// deprecated for this model` whatever the thinking mode, so the disable and
|
||||
/// no-reasoning paths have to drop it too.
|
||||
#[test]
|
||||
fn anthropic_thinking_config_drops_sampling_params_on_models_that_reject_them() {
|
||||
for model in [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"claude-fable-5",
|
||||
] {
|
||||
for effort in [Some("none"), None] {
|
||||
let (_, _, temperature) = anthropic_thinking_config(model, effort, Some(0.5));
|
||||
assert!(
|
||||
temperature.is_none(),
|
||||
"{model} must not carry temperature (effort {effort:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Sonnet 4.6 still accepts them, so an off selection keeps temperature.
|
||||
let (_, _, temperature) =
|
||||
anthropic_thinking_config("claude-sonnet-4-6", Some("none"), Some(0.5));
|
||||
assert_eq!(temperature, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_request_serializes_the_off_sentinel_as_a_thinking_disable() {
|
||||
let (thinking, output_config, temperature) =
|
||||
anthropic_thinking_config("claude-opus-5", Some("none"), Some(0.5));
|
||||
let request = AnthropicRequest {
|
||||
model: "claude-opus-5",
|
||||
system: None,
|
||||
messages: vec![],
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
temperature,
|
||||
thinking,
|
||||
output_config,
|
||||
max_tokens: Some(64000),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&request).unwrap()).unwrap();
|
||||
assert_eq!(body["thinking"]["type"], "disabled");
|
||||
// A disable paired with an effort is a 400 on Opus 5, and `display`
|
||||
// only applies to a thinking mode that actually runs.
|
||||
assert!(body["thinking"].get("display").is_none());
|
||||
assert!(body.get("output_config").is_none());
|
||||
assert!(body.get("temperature").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_request_omits_thinking_when_reasoning_off() {
|
||||
let request = AnthropicRequest {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//! - Stream event parsing
|
||||
//! - Helper utilities
|
||||
|
||||
use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
|
||||
use crate::{
|
||||
ai_bedrock::{
|
||||
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
|
||||
@@ -357,12 +358,11 @@ async fn handle_bedrock_sdk_streaming(
|
||||
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
|
||||
// Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
|
||||
let temperature = openai_req
|
||||
.reasoning_effort
|
||||
.is_none()
|
||||
.then_some(openai_req.temperature)
|
||||
.flatten();
|
||||
let temperature = bedrock_temperature(
|
||||
model,
|
||||
openai_req.reasoning_effort.as_deref(),
|
||||
openai_req.temperature,
|
||||
);
|
||||
let inference_config = create_inference_config(temperature, openai_req.max_tokens);
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
@@ -410,11 +410,35 @@ async fn handle_bedrock_sdk_streaming(
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the Converse `additionalModelRequestFields` enabling Claude adaptive
|
||||
/// thinking at the given effort. `display: summarized` is billing-neutral on
|
||||
/// Anthropic models and matches the direct-Anthropic chat path, which renders
|
||||
/// summarized thinking in the UI.
|
||||
/// Whether an effort token turns adaptive thinking on. `"none"` is the disable
|
||||
/// sentinel rather than a level.
|
||||
fn effort_enables_thinking(effort: Option<&str>) -> bool {
|
||||
matches!(effort, Some(effort) if effort != REASONING_OFF_SENTINEL)
|
||||
}
|
||||
|
||||
/// Temperature survives only when thinking is not adaptive — which rejects
|
||||
/// sampling params — and the model still accepts them at all. Non-Anthropic
|
||||
/// Bedrock models (Nova, Llama) are unaffected by the second check.
|
||||
fn bedrock_temperature(model: &str, effort: Option<&str>, temperature: Option<f32>) -> Option<f32> {
|
||||
if effort_enables_thinking(effort) || anthropic_model_rejects_sampling_params(model) {
|
||||
return None;
|
||||
}
|
||||
temperature
|
||||
}
|
||||
|
||||
/// Build the Converse `additionalModelRequestFields` carrying Claude's thinking
|
||||
/// config. `display: summarized` is billing-neutral on Anthropic models and
|
||||
/// matches the direct-Anthropic chat path, which renders summarized thinking in
|
||||
/// the UI.
|
||||
fn bedrock_thinking_fields(effort: &str) -> aws_smithy_types::Document {
|
||||
if effort == REASONING_OFF_SENTINEL {
|
||||
// The disable carries no effort: pairing it with xhigh or max is a 400
|
||||
// on Opus 5, and omitting it leaves the model at the effort where the
|
||||
// disable is accepted.
|
||||
return json_to_document(serde_json::json!({
|
||||
"thinking": { "type": "disabled" }
|
||||
}));
|
||||
}
|
||||
json_to_document(serde_json::json!({
|
||||
"thinking": { "type": "adaptive", "display": "summarized" },
|
||||
"output_config": { "effort": effort }
|
||||
@@ -663,12 +687,11 @@ async fn handle_bedrock_sdk_non_streaming(
|
||||
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
|
||||
// Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
|
||||
let temperature = openai_req
|
||||
.reasoning_effort
|
||||
.is_none()
|
||||
.then_some(openai_req.temperature)
|
||||
.flatten();
|
||||
let temperature = bedrock_temperature(
|
||||
model,
|
||||
openai_req.reasoning_effort.as_deref(),
|
||||
openai_req.temperature,
|
||||
);
|
||||
let inference_config = create_inference_config(temperature, openai_req.max_tokens);
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
@@ -934,8 +957,7 @@ impl BedrockQueryBuilder {
|
||||
let (bedrock_messages, system_prompts) =
|
||||
openai_messages_to_bedrock(&prepared_messages, enable_prompt_caching)?;
|
||||
|
||||
// Adaptive thinking rejects sampling params; drop temperature when reasoning is on.
|
||||
let temperature = reasoning_effort.is_none().then_some(temperature).flatten();
|
||||
let temperature = bedrock_temperature(model, reasoning_effort, temperature);
|
||||
|
||||
// Build inference configuration using shared helper
|
||||
let inference_config = create_inference_config(temperature, max_tokens.map(|t| t as i32));
|
||||
@@ -1285,6 +1307,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_thinking_fields_translate_the_off_sentinel_to_a_disable() {
|
||||
let fields = document_to_json(&bedrock_thinking_fields("none"));
|
||||
assert_eq!(fields["thinking"]["type"], "disabled");
|
||||
// An effort alongside the disable is a 400 on Opus 5.
|
||||
assert!(fields.get("output_config").is_none());
|
||||
assert!(effort_enables_thinking(Some("xhigh")));
|
||||
assert!(!effort_enables_thinking(Some("none")));
|
||||
assert!(!effort_enables_thinking(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_temperature_drops_sampling_params_per_thinking_mode_and_model() {
|
||||
// Adaptive thinking rejects sampling params on every model...
|
||||
let adaptive = bedrock_temperature("anthropic.claude-sonnet-4-6", Some("xhigh"), Some(0.5));
|
||||
assert!(adaptive.is_none());
|
||||
// ...while a model that still accepts them keeps them when off.
|
||||
let off = bedrock_temperature("anthropic.claude-sonnet-4-6", Some("none"), Some(0.5));
|
||||
assert_eq!(off, Some(0.5));
|
||||
// The models that removed them drop them on every mode.
|
||||
for (model, effort) in [
|
||||
("global.anthropic.claude-opus-5", Some("none")),
|
||||
("anthropic.claude-opus-4-8", None),
|
||||
] {
|
||||
assert!(bedrock_temperature(model, effort, Some(0.5)).is_none());
|
||||
}
|
||||
// A non-Anthropic Bedrock model keeps its sampling params.
|
||||
let nova = bedrock_temperature("amazon.nova-pro-v1:0", None, Some(0.5));
|
||||
assert_eq!(nova, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_thinking_fields_carry_adaptive_thinking_and_effort() {
|
||||
let fields = document_to_json(&bedrock_thinking_fields("xhigh"));
|
||||
|
||||
@@ -8,6 +8,38 @@ pub mod other;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The effort token the chat and agent surfaces send to turn reasoning off.
|
||||
/// It is not a provider-native level — each provider translates it to its own
|
||||
/// disable (Anthropic and Bedrock to `thinking: {type: "disabled"}`, DeepSeek to
|
||||
/// its `thinking` param, Gemini to a zero budget or the model's floor).
|
||||
pub(crate) const REASONING_OFF_SENTINEL: &str = "none";
|
||||
|
||||
/// Whether a Claude model removed the sampling params (`temperature`, `top_p`,
|
||||
/// `top_k`). On these, any value is a hard 400 — `temperature is deprecated for
|
||||
/// this model` — whatever the thinking mode, so the param has to be dropped on
|
||||
/// the reasoning-off and no-reasoning paths too, not only under adaptive
|
||||
/// thinking.
|
||||
///
|
||||
/// Probed against the Messages API: `claude-opus-5`, `claude-sonnet-5` and
|
||||
/// `claude-opus-4-8` reject them; `claude-sonnet-4-6` still accepts them. Opus
|
||||
/// 4.7, Fable and Mythos are included from Anthropic's migration guide, which
|
||||
/// documents the same removal, rather than from a probe.
|
||||
///
|
||||
/// Matching is on the model name, so a Bedrock *application* inference profile —
|
||||
/// whose id is opaque (`k1c3lwu20lem`) rather than derived from the model —
|
||||
/// cannot be classified and keeps its sampling params. Resolving the backing
|
||||
/// model would need a per-request AWS lookup; `bedrock_model_supports_prompt_caching`
|
||||
/// degrades on the same ids for the same reason.
|
||||
pub(crate) fn anthropic_model_rejects_sampling_params(model: &str) -> bool {
|
||||
let model = model.to_lowercase().replace('.', "-");
|
||||
model.contains("claude-opus-4-7")
|
||||
|| model.contains("claude-opus-4-8")
|
||||
|| model.contains("claude-opus-5")
|
||||
|| model.contains("claude-sonnet-5")
|
||||
|| model.contains("claude-fable")
|
||||
|| model.contains("claude-mythos")
|
||||
}
|
||||
|
||||
use windmill_common::cache::Cache;
|
||||
|
||||
use crate::{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user