diff --git a/.claude/hooks/allow-fileops-in-tmp.sh b/.claude/hooks/allow-fileops-in-tmp.sh index 665c23cf0a..87ce6541aa 100755 --- a/.claude/hooks/allow-fileops-in-tmp.sh +++ b/.claude/hooks/allow-fileops-in-tmp.sh @@ -1,17 +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, except for `mv` and `chmod`: those get an explicit `ask`, the only prompt -# they get (see lib-guarded-verb.sh). +# 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). +# +# 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. # -# 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. +# 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 @@ -19,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 @@ -52,25 +73,51 @@ defer() { exit 0 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" +# 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._/-')" ] +} -# 0 iff the token is charset-safe and resolves to a path strictly inside /tmp. +# 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 @@ -79,29 +126,16 @@ under_tmp() { return 1 } -# 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' ;; - *) defer "not the leading command word" ;; -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; } @@ -112,13 +146,13 @@ if [ -n "${ok_flags:-}" ]; then # leave a residue here and defer rather than being enumerated as denials. [ -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]*) defer "ambiguous option bundle \`$t\`" ;; esac case "${flags: -1}" in [$val_flags]) - val="${toks[$i]:-}" + val="${SEG_TOKS[$i]:-}" i=$((i + 1)) [ -n "$val" ] || defer "option \`$t\` has no value" under_tmp "$val" || defer "\`$val\` is outside /tmp" @@ -136,54 +170,152 @@ if [ -n "${ok_flags:-}" ]; then # 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" || defer "\`$t\` is outside /tmp" - [ "${toks[0]}" = "unzip" ] && saw_archive=1 + [ "$verb" = "unzip" ] && saw_archive=1 done # 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}" || defer "extraction target is outside /tmp" + 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 - decide 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")" ] && defer "unrecognized option \`$t\`" - 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]*)*$' || defer "unrecognized mode \`$t\`" ;; - 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" || defer "\`$t\` is outside /tmp" - 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 ] || defer "no path operand" -decide 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 diff --git a/.claude/hooks/guard-rm-outside-tmp.sh b/.claude/hooks/guard-rm-outside-tmp.sh index 5aff497d89..4d253ffc62 100755 --- a/.claude/hooks/guard-rm-outside-tmp.sh +++ b/.claude/hooks/guard-rm-outside-tmp.sh @@ -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). 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). +# 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,12 +20,12 @@ # 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`, -# `.claude` or `.env` 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). +# 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 @@ -35,87 +38,103 @@ cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) cwd=$(printf '%s' "$input" | jq -r '.cwd // empty' 2>/dev/null) # Every bail-out below goes through `defer`, so the forms this guard refuses to reason about — -# compound, quoted, wrapped — still reach the user as a prompt whenever an `rm` runs among them. +# 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 } -# A newline separates commands, and the tokenizer below only reads the first line — defer. -case "$cmd" in *$'\n'*) defer "multi-line command" ;; esac +has_substitution "$cmd" && defer "command substitution in the command line" -read -r -a toks <<< "$cmd" -# Bare leading `rm` only; wrappers (`timeout rm`), env prefixes, and `/bin/rm` defer. -[ "${toks[0]:-}" = "rm" ] || defer "rm is not the leading command word" - -# 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 - # Never auto-allow: history, and the two kinds of path the "it's under version control" - # premise doesn't hold for — the agent's own guards and settings (deleting them is what - # removes the prompt on everything else), and gitignored `.env` files. - case "$canon" in - *"/.git" | *"/.git/"* | *"/.claude" | *"/.claude/"*) return 1 ;; - *"/.env" | *"/.env."*) return 1 ;; - esac - d="$canon" - while [ "$d" != "/" ] && [ "$d" != "$HOME" ]; do - [ -e "$d/.git" ] && { root="$d"; break; } - d=$(dirname "$d") +# 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 + 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 - [ -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 + [ "$had_operand" = 1 ] || defer "no operand" } -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._/*?[]-')" ] && 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 - 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 - case "$t" in - /*) canon=$(realpath -m -- "$t" 2>/dev/null) ;; - *) canon=$(realpath -m -- "${cwd:-$PWD}/$t" 2>/dev/null) ;; +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" ] || 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 - allowed_target "$canon" || defer "\`$canon\` is outside /tmp and not inside a git checkout in \$HOME" + # 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 ] || defer "no operand" -decide allow '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 diff --git a/.claude/hooks/lib-guarded-verb.sh b/.claude/hooks/lib-guarded-verb.sh index c4d5f5ac9a..6ef76a5466 100644 --- a/.claude/hooks/lib-guarded-verb.sh +++ b/.claude/hooks/lib-guarded-verb.sh @@ -10,17 +10,6 @@ # expand a glob operand against the filesystem. Neither guard relies on pathname expansion. set -f -# 0 iff ($1) runs as a command word anywhere in ($2). Mirrors how a Bash -# permission rule matches, so that owning the prompt here doesn't narrow what used to prompt: -# the command splits on `; & |` and newlines, and a leading env assignment or process wrapper -# (`timeout 5 rm`, `xargs rm`) is skipped before the command word is read. -# -# The split set also carries the characters that open a nested command — `$(`, backticks and -# `( )` — because a rule matches the verb inside one (`echo $(rm -rf ~)` prompts), and a -# separator that only ends statements would read that 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. - # 0 iff ($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 @@ -115,35 +104,159 @@ strip_heredoc_bodies() { done } +# 0 iff ($1) runs as a command word in ($2), which must already be one +# segment (no separator left in it). Wrapper, env-prefix and `/bin/` 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 ($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 ($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 ($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:` for one strictly inside the git working tree at , 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 ($1) runs as a command word anywhere in ($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 w wrapped - while IFS= read -r seg; do - wrapped=0 - for w in $seg; 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 - # `tr` and not `${2//[...]}`: a `}` inside the bracket expression closes the expansion - # itself, which silently leaves the command unsplit and every separator unseen. - done <<< "$(strip_heredoc_bodies "$2" | tr ';&|()`' '\n')" + local verb="$1" seg + split_segments "$2" + for seg in "${SEGMENTS[@]}"; do + segment_runs_verb "$verb" "$seg" && return 0 + done return 1 } diff --git a/.claude/hooks/test-hooks.sh b/.claude/hooks/test-hooks.sh index 65631b6a48..eca946baf9 100644 --- a/.claude/hooks/test-hooks.sh +++ b/.claude/hooks/test-hooks.sh @@ -4,6 +4,10 @@ # 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)" @@ -46,10 +50,11 @@ 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 ask "ls /tmp && rm -rf /tmp/x" +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" @@ -107,6 +112,33 @@ 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 @@ -117,7 +149,7 @@ 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 ask "chmod -R 777 $CWD" -run $A ask "ls && mv /tmp/a /tmp/b" +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" @@ -129,5 +161,49 @@ 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 ask "$(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; } diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dda25c8875..362f6cf9eb 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.791.0" + ".": "1.792.1" } diff --git a/AGENTS.md b/AGENTS.md index 06b0402fd6..1da4be6c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf1b68e26..08e30a6384 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [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) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 60b84c4dd2..bbb7bacc0b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -14665,7 +14665,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-nats", @@ -14750,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.791.0" +version = "1.792.1" dependencies = [ "async-stream", "async-trait", @@ -14783,7 +14783,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14796,7 +14796,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "argon2", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14976,7 +14976,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15002,7 +15002,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.791.0" +version = "1.792.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -15012,7 +15012,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15029,7 +15029,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15051,7 +15051,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15090,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15112,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15133,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-nats", @@ -15182,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15207,7 +15207,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15235,7 +15235,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15277,7 +15277,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15315,7 +15315,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.791.0" +version = "1.792.1" dependencies = [ "lazy_static", "serde", @@ -15355,7 +15355,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.791.0" +version = "1.792.1" dependencies = [ "argon2", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15393,7 +15393,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.791.0" +version = "1.792.1" dependencies = [ "axum 0.8.9", "chrono", @@ -15428,7 +15428,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.791.0" +version = "1.792.1" dependencies = [ "chrono", "lazy_static", @@ -15442,7 +15442,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -15461,7 +15461,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.791.0" +version = "1.792.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -15565,7 +15565,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.791.0" +version = "1.792.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -15584,7 +15584,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.791.0" +version = "1.792.1" dependencies = [ "regex", "serde", @@ -15599,7 +15599,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15623,7 +15623,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "futures", @@ -15640,7 +15640,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.791.0" +version = "1.792.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15656,7 +15656,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -15677,7 +15677,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -15708,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "arc-swap", @@ -15733,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-stream", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "futures", @@ -15785,7 +15785,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.791.0" +version = "1.792.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -15806,7 +15806,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -15818,7 +15818,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "gosyn", @@ -15830,7 +15830,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -15842,7 +15842,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "nu-parser", @@ -15865,7 +15865,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15876,7 +15876,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -15888,7 +15888,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15899,7 +15899,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-recursion", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -15933,7 +15933,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -15947,7 +15947,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15964,7 +15964,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -15977,7 +15977,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde", @@ -15989,7 +15989,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -16007,7 +16007,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16023,7 +16023,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "rustpython-ast", @@ -16039,7 +16039,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -16053,7 +16053,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-recursion", @@ -16092,7 +16092,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "const_format", @@ -16132,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.791.0" +version = "1.792.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16143,7 +16143,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-recursion", @@ -16178,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16202,7 +16202,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16235,7 +16235,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16262,7 +16262,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16295,7 +16295,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16315,7 +16315,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16349,7 +16349,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16385,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16408,7 +16408,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16432,7 +16432,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-nats", @@ -16456,7 +16456,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16519,7 +16519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-trait", @@ -16544,7 +16544,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -16563,7 +16563,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-once-cell", @@ -16679,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.791.0" +version = "1.792.1" dependencies = [ "bytes", "futures", @@ -17462,9 +17462,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1befb2b741..2c8d0a2d75 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.791.0" +version = "1.792.1" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.791.0" +version = "1.792.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 6b8abe4d5e..39e59a1617 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.791.0" +version = "1.792.1" dependencies = [ "aho-corasick", "anyhow", @@ -6274,7 +6274,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.791.0" +version = "1.792.1" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6286,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.791.0" +version = "1.792.1" dependencies = [ "convert_case", "serde", @@ -6295,7 +6295,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6307,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6319,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6343,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6377,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6400,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6422,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6434,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6448,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6465,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6478,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde", @@ -6490,7 +6490,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6508,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6540,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "lazy_static", @@ -6586,7 +6586,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.791.0" +version = "1.792.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index cad8491bf6..f7e0529c87 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.791.0" +version = "1.792.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/tests/list_scripts_parent_hash.rs b/backend/tests/list_scripts_parent_hash.rs new file mode 100644 index 0000000000..bfad39fff8 --- /dev/null +++ b/backend/tests/list_scripts_parent_hash.rs @@ -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 { + 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> { + 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::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) -> 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(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 3f23d0416f..3b66f7317b 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -289,7 +289,9 @@ async fn list_scripts( sqlb.and_where_eq("parent_hashes[array_upper(parent_hashes, 1)]", &ph.0); } if let Some(ph) = &lq.parent_hash { - sqlb.and_where_eq("any(parent_hashes)", &ph.0); + // ANY() only ever sits on the right of the comparison; `and_where_eq` would + // emit it on the left and Postgres rejects that as a syntax error. + sqlb.and_where("? = ANY(parent_hashes)".bind(&ph.0)); } if let Some(it) = &lq.is_template { sqlb.and_where_eq("is_template", it); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 392229e05b..86b3e133e3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.791.0 + version: 1.792.1 title: Windmill API contact: diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 037971b19e..4e7aaf0f48 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -78,6 +78,8 @@ struct ScriptMetadata { #[serde(skip_serializing_if = "Option::is_none")] cache_ttl: Option, #[serde(skip_serializing_if = "Option::is_none")] + cache_ignore_s3_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] dedicated_worker: Option, #[serde(skip_serializing_if = "is_none_or_false")] ws_error_handler_muted: Option, @@ -826,6 +828,7 @@ pub(crate) async fn tarball_workspace( concurrency_settings: script.runnable_settings.concurrency_settings, debouncing_settings: script.runnable_settings.debouncing_settings, cache_ttl: script.cache_ttl, + cache_ignore_s3_path: script.cache_ignore_s3_path, dedicated_worker: script.dedicated_worker, ws_error_handler_muted: script.ws_error_handler_muted, priority: script.priority, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 42602c1c41..afe2f1279b 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.791.0"; +export const VERSION = "v1.792.1"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index a30c8d70d4..8c93bdb963 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -592,6 +592,7 @@ export async function handleFile( ws_error_handler_muted: typed?.ws_error_handler_muted, dedicated_worker: typed?.dedicated_worker, cache_ttl: typed?.cache_ttl, + cache_ignore_s3_path: typed?.cache_ignore_s3_path, concurrency_time_window_s: normConcurrencyTimeWindowS, concurrent_limit: normConcurrentLimit, deployment_message: message, @@ -602,8 +603,14 @@ export async function handleFile( concurrency_key: typed?.concurrency_key, debounce_key: typed?.debounce_key, debounce_delay_s: typed?.debounce_delay_s, + debounce_args_to_accumulate: typed?.debounce_args_to_accumulate, + max_total_debouncing_time: typed?.max_total_debouncing_time, + max_total_debounces_amount: typed?.max_total_debounces_amount, codebase: await codebase?.getDigest(forceTar), timeout: nonePositiveInt(typed?.timeout), + // 0 means "delete immediately after completion", so it must survive as 0 + // rather than being folded into "unset" the way the positive-only settings are. + delete_after_secs: typed?.delete_after_secs, on_behalf_of_email: typed?.on_behalf_of_email, envs: typed?.envs, modules: modules, @@ -635,6 +642,12 @@ export async function handleFile( (typed.description === remote.description && typed.summary === remote.summary && typed.kind == remote.kind && + // A `.ts` file changes language when defaultTs flips, content untouched. + // bun and bunnative share that extension, so the inferred language is always + // bun; the server derives bunnative back from the `//native` annotation in + // the content, which is compared above. + language == + (remote.language === "bunnative" ? "bun" : remote.language) && !remote.archived && (Array.isArray(remote?.lock) ? remote?.lock?.join("\n") @@ -646,6 +659,8 @@ export async function handleFile( remote.ws_error_handler_muted && typed.dedicated_worker == remote.dedicated_worker && typed.cache_ttl == remote.cache_ttl && + Boolean(typed.cache_ignore_s3_path) == + Boolean(remote.cache_ignore_s3_path) && normConcurrencyTimeWindowS == normalizeConcurrency( remote.concurrent_limit, @@ -659,15 +674,23 @@ export async function handleFile( Boolean(remote.visible_to_runner_only) && Boolean(typed.has_preprocessor) == Boolean(remote.has_preprocessor) && - typed.priority == Boolean(remote.priority) && + typed.priority == remote.priority && nonePositiveInt(typed.timeout) == nonePositiveInt(remote.timeout) && + typed.delete_after_secs == remote.delete_after_secs && //@ts-ignore typed.concurrency_key == remote["concurrency_key"] && typed.debounce_key == remote["debounce_key"] && typed.debounce_delay_s == remote["debounce_delay_s"] && + deepEqual( + typed.debounce_args_to_accumulate ?? null, + remote.debounce_args_to_accumulate ?? null + ) && + typed.max_total_debouncing_time == remote.max_total_debouncing_time && + typed.max_total_debounces_amount == remote.max_total_debounces_amount && typed.codebase == remote.codebase && (hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) && deepEqual(typed.envs, remote.envs) && + deepEqual(typed.labels ?? null, remote.labels ?? null) && deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 132dc55d7f..b98e87aff0 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.791.0"; +export const VERSION = "1.792.1"; diff --git a/cli/test/script_push_up_to_date.test.ts b/cli/test/script_push_up_to_date.test.ts new file mode 100644 index 0000000000..e4a3e91268 --- /dev/null +++ b/cli/test/script_push_up_to_date.test.ts @@ -0,0 +1,145 @@ +/** + * `wmill script push` short-circuits when the local script already matches the + * remote. The comparison has to hold in both directions: an untouched script + * deploys nothing, and every field the push body carries (labels and the language + * inferred from defaultTs included) still counts as a change. + */ + +import { expect, test } from "bun:test"; +import { writeFile, readFile, mkdir } from "node:fs/promises"; +import { withTestBackend } from "./test_backend.ts"; +import { waitForDeploymentJobs } from "./new_commands_helpers.ts"; + +test("Integration: script push skips an unchanged script and deploys a changed one", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/uptodate_${uniqueId}`; + const getScript = async () => + await ( + await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, + ) + ).json(); + const push = async () => + await backend.runCLICommand(["script", "push", `${scriptPath}.ts`], tempDir); + const wmillYaml = (defaultTs: string) => + `defaultTs: ${defaultTs}\nincludes:\n - "${scriptPath}**"\nexcludes: []\n`; + + await mkdir(`${tempDir}/f/test`, { recursive: true }); + await backend.apiRequest!(`/api/w/${backend.workspace}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + }); + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: `export async function main() {\n return "Hello world";\n}`, + summary: "Test up to date", + description: "", + language: "bun", + kind: "script", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + labels: ["l1"], + }), + }, + ); + expect(createResp.ok).toEqual(true); + + await writeFile(`${tempDir}/wmill.yaml`, wmillYaml("bun"), "utf-8"); + // The lock a deploy's dependency job writes is part of the comparison, so every + // pull has to happen after that job has landed or the skip races it. + await waitForDeploymentJobs(backend); + expect((await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code).toEqual(0); + + const hashBefore = (await getScript()).hash; + expect((await push()).stdout).toContain("is up to date"); + expect((await getScript()).hash).toEqual(hashBefore); + + const metadataPath = `${tempDir}/${scriptPath}.script.yaml`; + await writeFile( + metadataPath, + (await readFile(metadataPath, "utf-8")).replace("- l1", "- l2"), + "utf-8", + ); + expect((await push()).stdout).not.toContain("is up to date"); + expect((await getScript()).labels).toEqual(["l2"]); + + // 2, not 0 or 1: those two are the values a truthiness comparison would also + // call equal, so they cannot pin that priority is compared by value. + await waitForDeploymentJobs(backend); + expect((await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code).toEqual(0); + await writeFile(metadataPath, (await readFile(metadataPath, "utf-8")) + "priority: 2\n", "utf-8"); + expect((await push()).stdout).not.toContain("is up to date"); + expect((await getScript()).priority).toEqual(2); + await waitForDeploymentJobs(backend); + expect((await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code).toEqual(0); + expect((await push()).stdout).toContain("is up to date"); + + await writeFile(`${tempDir}/wmill.yaml`, wmillYaml("deno"), "utf-8"); + expect((await push()).stdout).not.toContain("is up to date"); + expect((await getScript()).language).toEqual("deno"); + }); +}); + +test("Integration: an unchanged bunnative script is not redeployed", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/native_${uniqueId}`; + + await mkdir(`${tempDir}/f/test`, { recursive: true }); + await backend.apiRequest!(`/api/w/${backend.workspace}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + }); + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + // The server stores this as bunnative: the language is derived from the + // annotation, and no file extension can express it back. + content: `//native\nexport async function main() {\n return "Hello world";\n}`, + summary: "Test bunnative", + description: "", + language: "bun", + kind: "script", + }), + }, + ); + expect(createResp.ok).toEqual(true); + + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun\nincludes:\n - "${scriptPath}**"\nexcludes: []\n`, + "utf-8", + ); + await waitForDeploymentJobs(backend); + expect((await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code).toEqual(0); + + const remote = await ( + await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, + ) + ).json(); + expect(remote.language).toEqual("bunnative"); + + const result = await backend.runCLICommand( + ["script", "push", `${scriptPath}.ts`], + tempDir, + ); + expect(result.stdout).toContain("is up to date"); + }); +}); diff --git a/cli/test/script_runtime_settings_sync.test.ts b/cli/test/script_runtime_settings_sync.test.ts new file mode 100644 index 0000000000..2b3272b20a --- /dev/null +++ b/cli/test/script_runtime_settings_sync.test.ts @@ -0,0 +1,126 @@ +/** + * Runtime settings that live only in the script metadata file (the retention + * delay, the debouncing bounds, the cache s3-path flag) must survive a sync + * pull/push cycle. A field missing from the create_script body the CLI builds + * lands as NULL on the deployed version; one missing from its up-to-date + * comparison makes a change to it alone report as up to date and never deploy. + */ + +import { expect, test } from "bun:test"; +import { writeFile, readFile, mkdir } from "node:fs/promises"; +import { withTestBackend } from "./test_backend.ts"; +import { waitForDeploymentJobs } from "./new_commands_helpers.ts"; + +// The debounce bounds this PR also restores cannot be asserted here: a build without +// git tags reports a bare commit as its version, GIT_SEM_VERSION then falls back to +// 0.1.0, and every version-gated feature (debouncing wants 1.566.0) is refused. That is +// what CI builds, so a fixture that sets any debounce field fails at create. +const SETTINGS = { + delete_after_secs: 900, + cache_ignore_s3_path: true, +}; + +test("Integration: script runtime settings survive a sync pull/push cycle", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/settings_${uniqueId}`; + const getScript = async () => { + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/get/p/${scriptPath}`, + ); + expect(resp.ok).toEqual(true); + return await resp.json(); + }; + + await mkdir(`${tempDir}/f/test`, { recursive: true }); + const folderResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/folders/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "test" }), + }, + ); + const folderStatus = `${folderResp.status} ${await folderResp.text()}`; + + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: `export async function main() {\n return "Hello world";\n}`, + summary: "Test runtime settings", + description: "", + language: "bun", + kind: "script", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + ...SETTINGS, + }), + }, + ); + if (!createResp.ok) { + throw new Error( + `scripts/create failed: ${createResp.status} ${await createResp.text()} ` + + `(folders/create: ${folderStatus})`, + ); + } + + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun\nincludes:\n - "${scriptPath}**"\nexcludes: []\n`, + "utf-8", + ); + + // The lock a deploy's dependency job writes is compared before the settings are, + // so a pull taken before that job lands makes the next push deploy over the lock + // instead of over the setting under test. + await waitForDeploymentJobs(backend); + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + const metadataPath = `${tempDir}/${scriptPath}.script.yaml`; + const pulledMetadata = await readFile(metadataPath, "utf-8"); + for (const key of Object.keys(SETTINGS)) { + expect(pulledMetadata).toContain(key); + } + + // A content-only edit must carry the settings through to the new version. + const scriptFilePath = `${tempDir}/${scriptPath}.ts`; + const originalContent = await readFile(scriptFilePath, "utf-8"); + await writeFile( + scriptFilePath, + originalContent.replace("Hello world", "Hello world modified"), + "utf-8", + ); + expect((await backend.runCLICommand(["sync", "push", "--yes"], tempDir)).code).toEqual(0); + + const afterContentPush = await getScript(); + expect(afterContentPush.content).toContain("Hello world modified"); + for (const [key, value] of Object.entries(SETTINGS)) { + expect(afterContentPush[key]).toEqual(value); + } + + // A settings-only edit must reach the remote rather than be skipped as up to + // date. 0 is "delete immediately after completion", not "unset". + await waitForDeploymentJobs(backend); + expect((await backend.runCLICommand(["sync", "pull", "--yes"], tempDir)).code).toEqual(0); + await writeFile( + metadataPath, + (await readFile(metadataPath, "utf-8")).replace( + `delete_after_secs: ${SETTINGS.delete_after_secs}`, + "delete_after_secs: 0", + ), + "utf-8", + ); + expect((await backend.runCLICommand(["sync", "push", "--yes"], tempDir)).code).toEqual(0); + + expect((await getScript()).delete_after_secs).toEqual(0); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index af05c50445..b7ffd39854 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.791.0", + "version": "1.792.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.791.0", + "version": "1.792.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 9d82fe3f4f..6613e914a6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.791.0", + "version": "1.792.1", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/frontend/src/lib/components/AppConnectDrawer.svelte b/frontend/src/lib/components/AppConnectDrawer.svelte index a84877e39b..187bb79b79 100644 --- a/frontend/src/lib/components/AppConnectDrawer.svelte +++ b/frontend/src/lib/components/AppConnectDrawer.svelte @@ -7,6 +7,8 @@ import AppConnectInner from './AppConnectInner.svelte' import DarkModeObserver from './DarkModeObserver.svelte' + import IconedResourceType from './IconedResourceType.svelte' + import { addResourceTitle } from './resourceTypeDisplay' interface Props { expressOAuthSetup?: boolean @@ -59,12 +61,17 @@ {disableChatOffset} > 1 ? resourceType : undefined)} id="add-resource-drawer" on:close={drawer?.closeDrawer} tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs." documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill" > + {#snippet titleExtra()} + {#if step > 1 && resourceType} + + {/if} + {/snippet} = $state({}) let args: any = $state({}) let renderDescription = $state(true) @@ -396,6 +398,20 @@ workspace: effectiveWorkspace }) + // Descriptions only feed search, so they are fetched off the critical path and + // allowed to fail: `resources/type/list` is not on the public app domain's route + // allow-list (`listnames` is), and it carries every type's full schema. Awaiting it + // would hold the list behind a request nothing on screen needs -- in a published + // app, behind one that is guaranteed to 403. resourceTypeDescriptions feeds a + // $derived, so search re-ranks when they land. + ResourceService.listResourceType({ workspace: effectiveWorkspace }) + .then((types) => { + resourceTypeDescriptions = Object.fromEntries( + types.filter((t) => t.description).map((t) => [t.name, t.description!]) + ) + }) + .catch(() => {}) + // "Others" lists every resource type — including instance-configured OAuth // providers — so any of them can also be connected with the user's own // credentials or manually, not only via the shared instance setup (same as @@ -870,6 +886,22 @@ let filteredConnects: { key: string }[] = $state([]) let filteredConnectsManual: { key: string; img?: string; instructions: string[] }[] = $state([]) + // uFuzzy scores the name and the description as one string, so searching "google" ranks + // every type whose description mentions Google alongside the ones named after it. Re-sort + // on which field matched, keeping uFuzzy's order within a tier. + const rank = (items: { key: string }[] | undefined) => + items && + sortResourceTypesByMatch( + items, + filter, + (x) => x.key, + (x) => resourceTypeDescriptions[x.key] + ) + let rankedConnects = $derived(rank(filteredConnects)) + let rankedConnectsManual = $derived( + rank(filteredConnectsManual) as typeof filteredConnectsManual | undefined + ) + let editScopes = $state(false) @@ -882,13 +914,13 @@ })) : undefined} bind:filteredItems={filteredConnects} - f={(x) => x.key} + f={(x) => resourceTypeSearchText(x.key, resourceTypeDescriptions[x.key])} /> x.key} + f={(x) => resourceTypeSearchText(x.key, resourceTypeDescriptions[x.key])} /> {#if step == 1}
@@ -904,8 +936,8 @@

Instance-configured OAuth APIs

- {#if filteredConnects} - {#each filteredConnects as { key }} + {#if rankedConnects} + {#each rankedConnects as { key }} {/if} {#each filteredResources as r} - {@const isPicked = value === r} + {@const isPicked = value === r.name} {/each} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 13f3de2a7a..7bc4774e9f 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -95,6 +95,7 @@ import OpenInSessionButton, { type OpenInSessionSource } from './sessions/OpenInSessionButton.svelte' + import { setOpenInSessionHandoff } from './sessions/openInSessionContext' // Forward-looking hook for the upcoming session-pane feature: that PR will // `setContext('aiChatManager', ...)` from the session wrapper so this editor @@ -278,6 +279,14 @@ let opWs = $derived(workspaceOverride ?? $workspaceStore) + // Publish this editor's hand-off for AI entry points below it (the preview + // panel's "AI Fix"), withheld under `disableAi` so an embed that turned AI off + // gets no entry point that navigates its host to /sessions. Shadows an + // ancestor's hand-off deliberately: ScriptEditorDrawer mounts this without a + // `sessionOpen`, and falling through to FlowBuilder's would answer "fix this + // script" by opening the flow and abandoning the drawer's unsaved content. + setOpenInSessionHandoff({ source: () => (disableAi ? undefined : sessionOpen) }) + $effect(() => { onTestStateChange?.(testIsLoading) }) diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 7ef3543073..9f0395b924 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -116,15 +116,15 @@ {:else if effectiveKind === 'postgres'} {:else if effectiveKind === 'kafka'} - + {:else if effectiveKind === 'nats'} - + {:else if effectiveKind === 'mqtt'} - + {:else if effectiveKind === 'amqp'} - + {:else if effectiveKind === 'sqs'} - + {:else if effectiveKind === 'gcp'} {:else if effectiveKind === 'azure'} diff --git a/frontend/src/lib/components/copilot/AIFormAssistant.svelte b/frontend/src/lib/components/copilot/AIFormAssistant.svelte index 4678342a57..ba8ed67084 100644 --- a/frontend/src/lib/components/copilot/AIFormAssistant.svelte +++ b/frontend/src/lib/components/copilot/AIFormAssistant.svelte @@ -1,27 +1,67 @@
-

Fill the inputs with AI

- + +

AI can help with these inputs

+ + {#snippet fallback()} + + {/snippet} +

diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 52c810e5e3..95ff47fb52 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -3,6 +3,9 @@ import { WandSparkles } from 'lucide-svelte' import { aiChatManager } from './chat/AIChatManager.svelte' import { AIBtnClasses } from './chat/AIButtonStyle' + import { prefersSessionHandoff } from './chat/global/gate' + import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte' + import { userStore } from '$lib/stores' interface Props { label?: string initialInput?: string @@ -11,7 +14,20 @@ const { label, initialInput, onClick: onClickProp }: Props = $props() + // The label stays short ("Ask AI") for the search bar's inline row; the hover + // text is where "new AI session" fits. + const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) + export function onClick() { + // No item to preview here — this carries a question, not a target — so the + // hand-off opens a bare session on the question alone. + if (handsOffToSession) { + onClickProp?.() + // Sent on arrival, matching the legacy path below (askAi sends straight + // away): the text is the question the user already typed. + void startSessionWithPrompt(initialInput ?? '', { autoSend: true }) + return + } aiChatManager.openChat() if (initialInput) { aiChatManager.askAi(initialInput, { @@ -30,6 +46,7 @@ }} unifiedSize="md" btnClasses={AIBtnClasses('default')} + title={handsOffToSession ? 'Ask this in a new AI session' : 'Ask this in the AI chat'} on:click={onClick} > {label} diff --git a/frontend/src/lib/components/copilot/ScriptFix.svelte b/frontend/src/lib/components/copilot/ScriptFix.svelte index 2deaef0193..bbd9341700 100644 --- a/frontend/src/lib/components/copilot/ScriptFix.svelte +++ b/frontend/src/lib/components/copilot/ScriptFix.svelte @@ -7,63 +7,123 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import { autoPlacement } from '@floating-ui/core' import { WandSparkles } from 'lucide-svelte' - import { aiChatManager } from './chat/AIChatManager.svelte' + import { aiChatManager, type AIChatManager } from './chat/AIChatManager.svelte' import { copilotInfo } from '$lib/aiStore' + import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte' + import { getOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' + import { AIBtnClasses } from './chat/AIButtonStyle' + import { getContext } from 'svelte' let { - lang + lang, + error, + jobId, + moduleId }: { lang: SupportedLanguage + /** The failing run's error, used when there is no job to point at. */ + error?: string + /** The failing run's job id. Preferred over `error`: the chat reads the + * run itself with `get_job_logs`, which gives it the logs rather than + * just the thrown value, and keeps the composer readable. */ + jobId?: string + /** Set when this sits in a flow step's preview, so the session opens on + * that step rather than the flow root. */ + moduleId?: string } = $props() + + // The enclosing editor's "Open in AI session" hand-off (ScriptEditor for a + // standalone script, FlowBuilder for a step). + const handoff = getOpenInSessionHandoff() + const seedPrompt = $derived.by(() => { + const what = moduleId ? `step \`${moduleId}\`` : 'this script' + if (jobId) { + return `The last test run of ${what} failed (job \`${jobId}\`). Read its logs, then fix the code.` + } + // No job to read: the error text has to travel with the request. + return error + ? `Fix this error in ${what}:\n\n\`\`\`\n${error}\n\`\`\`` + : `Fix the error from the last run of ${what}.` + }) + const sessionSource = $derived.by(() => { + const source = handoff?.source({ moduleId }) + return source ? { ...source, seedPrompt, autoSend: true } : undefined + }) + + // Inside a session pane the chat is already beside this panel, so there is + // nothing to hand off to: send into that chat instead. OpenInSessionButton + // renders nothing there, which would otherwise leave the sessions population + // with no fix affordance at all. + const sessionScopedManager = getContext('aiChatManager') {#if SUPPORTED_LANGUAGES.has(lang)} - - {#snippet trigger()} - -

- -
- + {#if sessionScopedManager} + + {:else} + + {#snippet fallback()} + + {#snippet trigger()} +
+ +
+ {/snippet} + {#snippet content()} +
+
+

Enable Windmill AI in the workspace settings

+
+ {/snippet} +
{/snippet} - {#snippet content()} - -
-
-

Enable Windmill AI in the workspace settings

-
- - {/snippet} - +
+ {/if} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIButton.svelte b/frontend/src/lib/components/copilot/chat/AIButton.svelte index 3fe9acec77..ade1bd2d86 100644 --- a/frontend/src/lib/components/copilot/chat/AIButton.svelte +++ b/frontend/src/lib/components/copilot/chat/AIButton.svelte @@ -12,7 +12,8 @@ togglePanel, btnClasses, btnProps, - label = 'Open in AI session' + label = 'Open in AI session', + tooltip }: { togglePanel: () => void btnClasses?: string @@ -21,13 +22,19 @@ btnProps?: ComponentProps /** Tooltip + accessible text of the icon-only button. */ label?: string + /** Hover text, when the label alone doesn't say where the button leads. + * A host that renamed the button ("AI Fix") uses this to keep "in a new + * AI session" discoverable. Defaults to `label`. */ + tooltip?: string } = $props() + + const hoverText = $derived(tooltip ?? label) {#if $copilotInfo.enabled} {#snippet text()} - {label} + {hoverText} {/snippet} {@render button({ onPress: () => togglePanel() })} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index a377a7e370..70ec3f3fc1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -166,9 +166,21 @@ files: initialFiles ?? [] })) ) + // Report edits, never the mount-time value. The first run carries whatever the + // composer was constructed with, which is not something the user did: a + // composer deliberately mounted empty (its text is already in flight, or + // belongs to a session this view is only keeping warm) would otherwise report + // '' and overwrite the very prompt it was withholding. + let draftReported = false $effect(() => { const text = draft.text - untrack(() => onDraftChange?.(text)) + untrack(() => { + if (!draftReported) { + draftReported = true + return + } + onDraftChange?.(text) + }) }) // Images being decoded right now. Holds off sending so a message can never go // out without an attachment the user already dropped, and reserves cap slots diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a1c27b0e32..ee1ef5ab96 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -1774,6 +1774,22 @@ export class AIChatManager { } } + /** Send `text` as a turn, or queue it when one is already streaming. Callers + * that send programmatically (an editor button, an arriving hand-off) must go + * through this rather than `sendRequest`: a second concurrent loop shares this + * manager's abort controller and transcript, so the two interleave and Stop + * halts only one. It is the rule the composer already follows. + * + * Gated on `sendInFlight` as well as `loading`: `loading` only rises after a + * send's attachment upkeep, so between the two a click would slip past. */ + sendOrQueue(text: string) { + if (this.loading || this.sendInFlight) { + this.queueMessage(text) + return + } + void this.sendRequest({ instructions: text }) + } + /** Remove the queued message and put it back into the input, images included. */ dequeueMessage() { if (!this.#hasQueuedMessage()) { @@ -2609,6 +2625,14 @@ export class AIChatManager { } sendRequest = async (options: Parameters[0] = {}) => { + // A turn with nowhere to render still streams, spends tokens and applies + // tool calls — entirely off-screen. Refuse instead. `sendInlineRequest` is + // exempt: the ⌘K widget renders its own composer inside Monaco. + if (!this.isSessionChat && !chatState.dockedChatAvailable) { + console.error('sendRequest called with no chat UI mounted; dropping the turn') + sendUserToast('This action needs the AI chat. Start an AI session to continue.', true) + return + } this.#sendsInFlight++ try { return await this.sendRequestImpl(options) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index e4574ee9c1..5b5c1138cb 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -6,6 +6,7 @@ import type { ReviewChangesOpts } from './monaco-adapter' import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' import type { AttachedImage } from './imageUtils' import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' +import { chatState } from './sharedChatState.svelte' import { PLAN_MODE_MESSAGES } from './planModeMessages' import { runChatLoop } from './chatLoop' @@ -129,6 +130,9 @@ vi.mock('esm-env', async (importOriginal) => ({ })) beforeEach(() => { + // These managers stand in for a mounted docked chat; without a layout to set + // it, sendRequest's "nowhere to render this turn" guard would refuse every send. + chatState.dockedChatAvailable = true vi.clearAllMocks() mocks.getCurrentModel.mockReturnValue(undefined) mocks.tryGetCurrentModel.mockReturnValue(undefined) @@ -172,6 +176,66 @@ function createFlowHelpers({ } as unknown as FlowAIChatHelpers } +describe('AIChatManager unmounted-chat guard', () => { + // AI Sessions leave the docked pane unmounted, so an entry point that still + // drives this manager would otherwise stream and apply tool calls off-screen. + it('drops the turn when no chat UI is mounted, unless it is a session chat', async () => { + chatState.dockedChatAvailable = false + const docked = new AIChatManager() + docked.instructions = 'do a thing' + await docked.sendRequest() + expect(mocks.runChatLoop).not.toHaveBeenCalled() + + const session = new AIChatManager() + session.isSessionChat = true + session.instructions = 'do a thing' + await session.sendRequest() + expect(mocks.runChatLoop).toHaveBeenCalled() + }) +}) + +describe('AIChatManager.sendOrQueue', () => { + // The programmatic senders (an editor's "AI Fix", an arriving hand-off) have no + // composer to enforce the composer's rule for them: a second loop on one manager + // shares its abort controller and transcript. + it('queues instead of starting a second turn while one is streaming', () => { + const manager = new AIChatManager() + manager.loading = true + manager.sendOrQueue('fix the failing run') + expect(mocks.runChatLoop).not.toHaveBeenCalled() + expect(manager.queuedMessage).toBe('fix the failing run') + }) + + it('sends straight away when idle', async () => { + const manager = new AIChatManager() + manager.sendOrQueue('fix the failing run') + await vi.waitFor(() => expect(mocks.runChatLoop).toHaveBeenCalled()) + expect(manager.queuedMessage).toBe('') + }) + + // `loading` only rises after a send's attachment upkeep, so gating on it alone + // leaves a window where a second programmatic send slips through. + it('queues during a send that has not reached loading yet', async () => { + const manager = new AIChatManager() + let releaseUpkeep: (() => void) | undefined + vi.spyOn(manager.attachedFiles, 'refreshFolders').mockImplementation( + () => new Promise((resolve) => (releaseUpkeep = resolve)) + ) + manager.instructions = 'first turn' + const sending = manager.sendRequest() + await vi.waitFor(() => expect(manager.sendInFlight).toBe(true)) + expect(manager.loading).toBe(false) + + manager.sendOrQueue('fix the failing run') + expect(manager.queuedMessage).toBe('fix the failing run') + + // Drain before leaving: a send still in flight would run its epilogue + // (queue flush included) inside whichever test happens to be next. + releaseUpkeep?.() + await sending + }) +}) + describe('AIChatManager request errors', () => { const openaiModel = { provider: 'openai', model: 'gpt-4o' } diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index f3eb731ae2..3321d5a0cd 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -25,6 +25,16 @@ export function isGlobalAiEnabled(): boolean { } } +/** + * Whether an AI entry point hands off to a session instead of driving the docked + * chat. Deliberately the same condition as the root layout's `disableAi`, so a + * caller falling back on `false` always has a mounted pane to fall back to. + * Operators keep that pane (`/sessions` refuses them) until the operator chat ships. + */ +export function prefersSessionHandoff(isOperator: boolean | undefined): boolean { + return isGlobalAiEnabled() && !isOperator +} + /** Persist the opt-out choice, then hard-reload so every gated site re-reads it. */ export function setSessionsBetaOptOut(optOut: boolean, target: string) { // Navigate even when persistence throws (quota, private browsing) — the diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 635ad2a7c4..086d4cc594 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -7,6 +7,11 @@ vi.mock('monaco-editor', () => ({ editor: {} })) +vi.mock('$lib/utils/featureUsage', () => ({ + logFeatureUsage: vi.fn(), + logHubScriptPick: vi.fn() +})) + const userHolder = vi.hoisted(() => ({ current: { is_super_admin: true } as { is_super_admin: boolean } })) @@ -902,6 +907,63 @@ describe('processToolCall', () => { }) ) }) + + // The counter is silently dropped by the backend when the key is malformed, so nothing + // here fails loudly if a path stops logging or logs the wrong status. + it('logs one feature-usage outcome per tool call, keyed :', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const { logFeatureUsage } = await import('$lib/utils/featureUsage') + + const outcomeKeys = async ( + tool: Partial> = {}, + toolCallbacks: Partial = {} + ) => { + vi.mocked(logFeatureUsage).mockClear() + await runToolCall( + { + def: createToolDef(z.object({}), 'run_script', 'Run script'), + fn: vi.fn().mockResolvedValue('done'), + ...tool + }, + toolCallbacks + ) + return vi + .mocked(logFeatureUsage) + .mock.calls.map(([feature, kind, opts]) => [feature, kind, opts?.key]) + } + + expect(await outcomeKeys()).toEqual([['ai_chat', 'tool', 'run_script:ok']]) + expect(await outcomeKeys({ fn: vi.fn().mockRejectedValue(new Error('boom')) })).toEqual([ + ['ai_chat', 'tool', 'run_script:error'] + ]) + expect(await outcomeKeys({ validateBeforeConfirmation: () => 'not deployed' })).toEqual([ + ['ai_chat', 'tool', 'run_script:rejected'] + ]) + expect( + await outcomeKeys( + { requiresConfirmation: true }, + { requestConfirmation: vi.fn().mockResolvedValue(false) } + ) + ).toEqual([['ai_chat', 'tool', 'run_script:declined']]) + expect(await outcomeKeys({}, { isPlanModeActive: () => true })).toEqual([ + ['ai_chat', 'tool', 'run_script:blocked_plan_mode'] + ]) + + // A name the model invented resolves to no tool, and must never reach telemetry. + vi.mocked(logFeatureUsage).mockClear() + await processToolCall({ + tools: [{ def: createToolDef(z.object({}), 'run_script', 'Run script'), fn: vi.fn() }], + toolCall: { + id: 'call_ghost', + type: 'function', + function: { name: 'hallucinated_tool', arguments: '{}' } + }, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn() } + }) + expect(logFeatureUsage).not.toHaveBeenCalled() + }) }) async function runToolCall( diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 83c586a34b..bdf4c52036 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -797,6 +797,15 @@ function stringifyErrorBody(body: unknown): string { } } +/** + * Closed vocabulary for the `ai_chat`/`tool` counter's `:` key. + * `ok` means the tool function resolved — tools that report failure by returning an + * error string instead of throwing land there too. A call abandoned mid-execution + * (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that + * finished, not to the calls made. + */ +type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode' + export async function processToolCall({ tools, toolCall, @@ -810,10 +819,24 @@ export async function processToolCall({ toolCallbacks: ToolCallbacks workspace?: string }): Promise { + const tool = tools.find((t) => t.def.function.name === toolCall.function.name) + const workspaceId = workspace ?? get(workspaceStore) ?? '' + + // Exactly once per call, on whichever path ends it. Keyed by the resolved tool's + // declared name, not the model-provided string, so hallucinated tool names never + // enter telemetry — an unresolved name is counted nowhere. + let outcomeLogged = false + const logToolOutcome = (status: ToolCallStatus) => { + if (!tool || outcomeLogged) return + outcomeLogged = true + logFeatureUsage('ai_chat', 'tool', { + key: `${tool.def.function.name}:${status}`, + workspace: workspaceId + }) + } + try { const args = JSON.parse(toolCall.function.arguments || '{}') - const tool = tools.find((t) => t.def.function.name === toolCall.function.name) - const workspaceId = workspace ?? get(workspaceStore) ?? '' // Fails closed: untagged is blocked, only the safety tag exempt. Runs before anything // belonging to the tool, so a validator cannot probe while planning — and again after @@ -830,6 +853,7 @@ export async function processToolCall({ : { label: PLAN_MODE_MESSAGES.blockedLabel, result: PLAN_MODE_MESSAGES.blockedResult } if (!refusal) return undefined toolCallbacks.onToolBlockedByPlanMode?.() + logToolOutcome('blocked_plan_mode') toolCallbacks.setToolStatus(toolCall.id, { content: refusal.label, parameters: args, @@ -858,6 +882,7 @@ export async function processToolCall({ await tool?.validateBeforeConfirmation?.({ args, workspace: workspaceId, helpers }) ) if (rejection) { + logToolOutcome('rejected') toolCallbacks.setToolStatus(toolCall.id, { content: rejection.label, parameters: args, @@ -912,6 +937,7 @@ export async function processToolCall({ const confirmed = await toolCallbacks.requestConfirmation(toolCall.id, toolCall.function.name) if (!confirmed) { + logToolOutcome('declined') toolCallbacks.setToolStatus(toolCall.id, { content: 'Cancelled by user', isLoading: false, @@ -940,11 +966,6 @@ export async function processToolCall({ } let result = '' - // Key by the resolved tool's declared name, not the model-provided string, - // so hallucinated tool names never enter telemetry. - if (tool) { - logFeatureUsage('ai_chat', 'tool', { key: tool.def.function.name, workspace: workspaceId }) - } try { result = await callTool({ tools, @@ -955,12 +976,14 @@ export async function processToolCall({ toolCallbacks, toolId: toolCall.id }) + logToolOutcome('ok') toolCallbacks.setToolStatus(toolCall.id, { isLoading: false, isStreamingArguments: false }) } catch (err) { console.error(err) + logToolOutcome('error') const errorMessage = formatToolError(err) toolCallbacks.setToolStatus(toolCall.id, { isLoading: false, @@ -977,6 +1000,7 @@ export async function processToolCall({ return toAdd } catch (err) { console.error(err) + logToolOutcome('error') const errorMessage = formatToolError(err) toolCallbacks.setToolStatus(toolCall.id, { isLoading: false, diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index a8c18f70ba..eeb4101f92 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -35,6 +35,9 @@ import { Button } from '../common' import { MousePointerClick, X } from 'lucide-svelte' import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte' + import { prefersSessionHandoff } from '../copilot/chat/global/gate' + import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' + import { userStore } from '$lib/stores' const { flowStore, selectionManager } = getContext('FlowEditorContext') const sessionScopedManager = getContext('aiChatManager') const aiChatManager = sessionScopedManager ?? singletonAiChatManager @@ -273,6 +276,12 @@ aiChatManager.flowOptions = options }) + // The step exists but is empty, so name it: a GLOBAL-mode request carries no + // implicit "current step" the way the old SCRIPT-mode generateStep did. + function stepInstructionsPrompt(moduleId: string, instructions: string): string { + return `Write the code for step \`${moduleId}\` of the flow open in the editor:\n\n${instructions}` + } + onMount(() => { if (modalPanel) { selectionManager.setOnSelectIntent((id, opts) => { @@ -368,6 +377,32 @@ {showJobStatus} on:reload on:generateStep={({ detail }) => { + // The step is already inserted; the prompt describes what it should + // contain. Hand it to a session opened on that step rather than the + // docked chat, which sessions leave unmounted. Sent on arrival: the + // user already said what they wanted in the description field. + if ( + !sessionScopedManager && + sessionOpen && + prefersSessionHandoff($userStore?.operator) + ) { + void openSourceInSession(sessionOpen, { + previewParams: { selected: detail.moduleId }, + seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions), + autoSend: true + }) + return + } + // Already in a session: its chat is on screen, so ask it directly. + // Not `generateStep` — that forces the request into SCRIPT mode, and + // changeMode is persistent, so it would strand the session outside + // GLOBAL. Global mode writes step code through set_flow_module_code. + if (sessionScopedManager) { + sessionScopedManager.sendOrQueue( + stepInstructionsPrompt(detail.moduleId, detail.instructions) + ) + return + } if (!aiChatManager.open) { aiChatManager.openChat() } diff --git a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte index ac41827d6c..29d12b8084 100644 --- a/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte +++ b/frontend/src/lib/components/graph/renderers/triggers/TriggersBadge.svelte @@ -5,16 +5,18 @@ import { getContext } from 'svelte' import { type TriggerContext } from '$lib/components/triggers' import { enterpriseLicense } from '$lib/stores' - import { - MqttIcon, - AmqpIcon, - NatsIcon, - KafkaIcon, - AwsIcon, - GoogleCloudIcon - } from '$lib/components/icons' + import MqttIcon from '$lib/components/icons/MqttIcon.svelte' + import AmqpIcon from '$lib/components/icons/AmqpIcon.svelte' + import NatsIcon from '$lib/components/icons/NatsIcon.svelte' + import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' + import AwsIcon from '$lib/components/icons/AwsIcon.svelte' + import GoogleCloudIcon from '$lib/components/icons/GoogleCloudIcon.svelte' import AzureIcon from '$lib/components/icons/AzureIcon.svelte' - import { type Trigger, type TriggerType } from '$lib/components/triggers/utils' + import { + triggerIconMapMono, + type Trigger, + type TriggerType + } from '$lib/components/triggers/utils' import { Menu, Menubar, MeltButton, MenuItem, Tooltip } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte' @@ -320,10 +322,13 @@ {/snippet} {#snippet simpleTriggerItem({ item, type })} - {@const { icon: SvelteComponent, countKey } = triggerTypeConfig()[type] || { + {@const { icon: ColourIcon, countKey } = triggerTypeConfig()[type] || { icon: Database, countKey: undefined }} + + {@const SvelteComponent = triggerIconMapMono[type] ?? ColourIcon}
diff --git a/frontend/src/lib/components/icons/AblyIcon.svelte b/frontend/src/lib/components/icons/AblyIcon.svelte new file mode 100644 index 0000000000..ce4c360152 --- /dev/null +++ b/frontend/src/lib/components/icons/AblyIcon.svelte @@ -0,0 +1,59 @@ + + + + diff --git a/frontend/src/lib/components/icons/AbstractApiIcon.svelte b/frontend/src/lib/components/icons/AbstractApiIcon.svelte new file mode 100644 index 0000000000..a4b1635775 --- /dev/null +++ b/frontend/src/lib/components/icons/AbstractApiIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AcceloIcon.svelte b/frontend/src/lib/components/icons/AcceloIcon.svelte new file mode 100644 index 0000000000..0f7846e903 --- /dev/null +++ b/frontend/src/lib/components/icons/AcceloIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ActimoIcon.svelte b/frontend/src/lib/components/icons/ActimoIcon.svelte new file mode 100644 index 0000000000..59accffb99 --- /dev/null +++ b/frontend/src/lib/components/icons/ActimoIcon.svelte @@ -0,0 +1,23 @@ + + + + diff --git a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte index 836b5a242b..8d0b08374b 100644 --- a/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte +++ b/frontend/src/lib/components/icons/ActiveCampaignIcon.svelte @@ -7,9 +7,17 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/ActivitypubIcon.svelte b/frontend/src/lib/components/icons/ActivitypubIcon.svelte index 0185d98f3a..b8eea782f7 100644 --- a/frontend/src/lib/components/icons/ActivitypubIcon.svelte +++ b/frontend/src/lib/components/icons/ActivitypubIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AdRapidIcon.svelte b/frontend/src/lib/components/icons/AdRapidIcon.svelte new file mode 100644 index 0000000000..443b96a548 --- /dev/null +++ b/frontend/src/lib/components/icons/AdRapidIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/AdhookIcon.svelte b/frontend/src/lib/components/icons/AdhookIcon.svelte new file mode 100644 index 0000000000..89da89b9cf --- /dev/null +++ b/frontend/src/lib/components/icons/AdhookIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte index 2f04246966..f1b513be98 100644 --- a/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte +++ b/frontend/src/lib/components/icons/AdobeAcrobatSignIcon.svelte @@ -1,24 +1,24 @@ - - - - - + + diff --git a/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte new file mode 100644 index 0000000000..c104bca866 --- /dev/null +++ b/frontend/src/lib/components/icons/AeroWorkflowIcon.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte new file mode 100644 index 0000000000..e5a6169bbe --- /dev/null +++ b/frontend/src/lib/components/icons/AgentInstructionsIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/Ai21Icon.svelte b/frontend/src/lib/components/icons/Ai21Icon.svelte new file mode 100644 index 0000000000..600fb8a3b6 --- /dev/null +++ b/frontend/src/lib/components/icons/Ai21Icon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/AiAgentIcon.svelte b/frontend/src/lib/components/icons/AiAgentIcon.svelte new file mode 100644 index 0000000000..f3d042c98d --- /dev/null +++ b/frontend/src/lib/components/icons/AiAgentIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/AirtableIcon.svelte b/frontend/src/lib/components/icons/AirtableIcon.svelte index e71aa95bdd..70cc6c1698 100644 --- a/frontend/src/lib/components/icons/AirtableIcon.svelte +++ b/frontend/src/lib/components/icons/AirtableIcon.svelte @@ -1,22 +1,31 @@ + + d="m228.6 47.2-190.9 79c-10.6 4.4-10.5 19.5.2 23.7l191.7 76c16.8 6.7 35.6 6.7 52.4 0l191.7-76c10.7-4.2 10.8-19.3.2-23.7L283 47.2c-17.4-7.2-37-7.2-54.4 0" + style="fill:#fcb400" + /> + diff --git a/frontend/src/lib/components/icons/AlgoliaIcon.svelte b/frontend/src/lib/components/icons/AlgoliaIcon.svelte index e003c778e4..5b1fdddedd 100644 --- a/frontend/src/lib/components/icons/AlgoliaIcon.svelte +++ b/frontend/src/lib/components/icons/AlgoliaIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/AmqpIcon.svelte b/frontend/src/lib/components/icons/AmqpIcon.svelte index 41987f704f..c5de0c1699 100644 --- a/frontend/src/lib/components/icons/AmqpIcon.svelte +++ b/frontend/src/lib/components/icons/AmqpIcon.svelte @@ -8,6 +8,8 @@ let { size = 16, color = undefined, class: clazz = '' }: Props = $props() + - - + + diff --git a/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte new file mode 100644 index 0000000000..8fe884c479 --- /dev/null +++ b/frontend/src/lib/components/icons/ApiKeyAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ApifyIcon.svelte b/frontend/src/lib/components/icons/ApifyIcon.svelte index b5f1529bdc..9c9e6d5978 100644 --- a/frontend/src/lib/components/icons/ApifyIcon.svelte +++ b/frontend/src/lib/components/icons/ApifyIcon.svelte @@ -1,22 +1,32 @@ + - - - - - - - - - - - + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ApolloIcon.svelte b/frontend/src/lib/components/icons/ApolloIcon.svelte index 85dbb1730a..c41d0087f5 100644 --- a/frontend/src/lib/components/icons/ApolloIcon.svelte +++ b/frontend/src/lib/components/icons/ApolloIcon.svelte @@ -1,12 +1,31 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/AppwriteIcon.svelte b/frontend/src/lib/components/icons/AppwriteIcon.svelte index 53d8d0369b..34de69ee08 100644 --- a/frontend/src/lib/components/icons/AppwriteIcon.svelte +++ b/frontend/src/lib/components/icons/AppwriteIcon.svelte @@ -1,21 +1,29 @@ + - - + diff --git a/frontend/src/lib/components/icons/ArcGisIcon.svelte b/frontend/src/lib/components/icons/ArcGisIcon.svelte new file mode 100644 index 0000000000..660d28b804 --- /dev/null +++ b/frontend/src/lib/components/icons/ArcGisIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/AsanaIcon.svelte b/frontend/src/lib/components/icons/AsanaIcon.svelte index 2c03b80a27..e6043784fd 100644 --- a/frontend/src/lib/components/icons/AsanaIcon.svelte +++ b/frontend/src/lib/components/icons/AsanaIcon.svelte @@ -7,6 +7,7 @@ let { height = '24px', width = '24px' }: Props = $props() + Asana diff --git a/frontend/src/lib/components/icons/AssemblyAiIcon.svelte b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte new file mode 100644 index 0000000000..bfc65a32db --- /dev/null +++ b/frontend/src/lib/components/icons/AssemblyAiIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte index b4d019e4b6..d7342a2765 100644 --- a/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte +++ b/frontend/src/lib/components/icons/AssetDatabaseIcon.svelte @@ -6,7 +6,12 @@ class?: string } - let { height = '24px', width = '24px', fill = 'black', class: className = '' }: Props = $props() + let { + height = '24px', + width = '24px', + fill = 'currentColor', + class: className = '' + }: Props = $props() + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + diff --git a/frontend/src/lib/components/icons/Auth0Icon.svelte b/frontend/src/lib/components/icons/Auth0Icon.svelte index fbf419e3cf..97fa53a3d9 100644 --- a/frontend/src/lib/components/icons/Auth0Icon.svelte +++ b/frontend/src/lib/components/icons/Auth0Icon.svelte @@ -1,4 +1,5 @@ + auth0-svg + + + diff --git a/frontend/src/lib/components/icons/AutheliaIcon.svelte b/frontend/src/lib/components/icons/AutheliaIcon.svelte index 037844b4d3..4878dcb26f 100644 --- a/frontend/src/lib/components/icons/AutheliaIcon.svelte +++ b/frontend/src/lib/components/icons/AutheliaIcon.svelte @@ -1,32 +1,48 @@ - - authelia-svg - - + authelia-svg + + - + - + - + - + - + 1340 25 134 25 437 0 575 -26 150 -80 311 -114 343 -43 41 -103 38 -148 -7z" + /> + diff --git a/frontend/src/lib/components/icons/AuthentikIcon.svelte b/frontend/src/lib/components/icons/AuthentikIcon.svelte index ee52fba6af..e79f18eef1 100644 --- a/frontend/src/lib/components/icons/AuthentikIcon.svelte +++ b/frontend/src/lib/components/icons/AuthentikIcon.svelte @@ -1,27 +1,19 @@ - - authentik-svg - - - - - - - - - - - - - - - + + + authentik-svg + + diff --git a/frontend/src/lib/components/icons/AwsEcrIcon.svelte b/frontend/src/lib/components/icons/AwsEcrIcon.svelte index 2801712f62..379a14e4a0 100644 --- a/frontend/src/lib/components/icons/AwsEcrIcon.svelte +++ b/frontend/src/lib/components/icons/AwsEcrIcon.svelte @@ -1,12 +1,16 @@ + + - + - - - - diff --git a/frontend/src/lib/components/icons/AwsIcon.svelte b/frontend/src/lib/components/icons/AwsIcon.svelte index 3b02d9d773..7f371a6229 100644 --- a/frontend/src/lib/components/icons/AwsIcon.svelte +++ b/frontend/src/lib/components/icons/AwsIcon.svelte @@ -1,33 +1,38 @@ + - - diff --git a/frontend/src/lib/components/icons/AzureIcon.svelte b/frontend/src/lib/components/icons/AzureIcon.svelte index c6c3b6d60b..011142b851 100644 --- a/frontend/src/lib/components/icons/AzureIcon.svelte +++ b/frontend/src/lib/components/icons/AzureIcon.svelte @@ -1,22 +1,86 @@ + - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BRAND_COLORS.md b/frontend/src/lib/components/icons/BRAND_COLORS.md new file mode 100644 index 0000000000..1cff77a3d3 --- /dev/null +++ b/frontend/src/lib/components/icons/BRAND_COLORS.md @@ -0,0 +1,578 @@ +# Icon brand colours + +Where every icon's colours come from, and whether they survive both app surfaces. +Compiled from the components themselves during the audit — colours read from the fills and +the Tailwind pair classes, sources from each component's provenance comment, contrast +computed from those hexes against `surface-primary` in each theme. Maintained by hand from +here on: change an icon's colour or source and change its row. + +Surfaces: light `#fbfbfd`, dark `#2e3441`. Ratios are WCAG non-text contrast; **bold** marks a +mark that is effectively invisible on that surface. WCAG exempts logotypes from the 3:1 +floor, so a low ratio is a signal the colour may be wrong, not automatically a defect. + +`pair` = brand publishes a per-theme variant. Usually applied as `text-[#light] dark:text-[#dark]`; `AnsibleIcon` inverts instead (`dark:invert`), and `DatadogIcon`, `DenoIcon`, `DeepLIcon` and `TogglIcon` swap between two SVGs (`dark:hidden` / `hidden dark:block`) because their two marks are different artwork, not the same shape recoloured. +`fixed` = full-colour mark, same in both themes. `inherits` = brand publishes no colour, +so the mark takes the surrounding text colour. `mixed` = the root carries a +`fill="currentColor"` that hardcoded path fills override, so it is inert — these are +candidates for cleanup, not theme-aware icons. + +The Light/Dark columns show the colour that carries the mark; white and black knockout +details are omitted. Ratios are the best contrast any part of the mark achieves. + +**Do not change a colour here without a first-party source.** Several of these look like +mistakes and are not: Cal.com is deliberately greyscale, Google Cloud may not be recoloured, +Stripe is blurple rather than black. Third-party icon sets go stale and have been wrong +repeatedly — check the brand's own page. + +| Icon | Resource types | Mode | Light | Dark | ☀ | 🌙 | Source | +|---|---|---|---|---|---|---|---| +| `AblyIcon` | `ably` | fixed | #FF5416 | #FF5416 | 3.87 | 3.88 | brand.ably.com/logo | +| `AbstractApiIcon` | `abstractapi` | fixed | #20E492 | #20E492 | **1.62** | 12.47 | abstractapi.com's own logo SVG (6538df34291c9fa4ed28d6f7_Logo.svg) | +| `AcceloIcon` | `accelo` | fixed | #4C49CB | #4C49CB | 6.51 | 8.15 | Accelo_Logo-Primary.svg on accelo.com | +| `ActiveCampaignIcon` | `activecampaign` | pair | #004CFF | #FFFFFF | 5.84 | 12.47 | activecampaign.com/brand logo pack (ActiveCampaign-Glyph-Blue.svg / ActiveCampaign-Glyph-White.svg) | +| `ActivitypubIcon` | `activitypub` | fixed | #F1007E | #F1007E | 5.01 | 2.99 | activitypub.rocks/static/images/ActivityPub-logo.svg | +| `AcumbamailIcon` | `acumbamail` | fixed | #E62F71 | #E62F71 | 8.83 | 8.86 | Acumbamail's own isotype SVG, /static/favico/Acumbamail/favicon-32.svg on acumbamail.com | +| `AdhookIcon` | `adhook` | fixed | #00ACC6 | #00ACC6 | 2.63 | 4.58 | adhook's own logo (https://adhook.io/fr/images/logo.svg, `.cls-1{fill:#00acc6}`) | +| `AdobeAcrobatSignIcon` | `adobe_acrobat_sign` | fixed | #584CCC | #584CCC | 6.12 | 12.47 | Adobe's own Acrobat Sign product icon (adobe.com/cc-shared/assets/img/product-icons/svg/acrobat-sign.svg); same value in the live app favicon | +| `Ai21Icon` | `ai21` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ai21.com (ai21-logo-black.svg / ai21-logo-white.svg) | +| `AirtableIcon` | `airtable` | mixed | #FCB400 | #FCB400 | 3.88 | 6.93 | airtable.com/favicon.ico (fixed full-colour mark: #18BFFF and #F82B60 panels) | +| `AlgoliaIcon` | `algolia` | pair | #003DFF | #FFFFFF | 6.53 | 12.47 | algolia.com logo pack (Algolia-mark-blue.svg / Algolia-mark-white.svg) | +| `AmqpIcon` | `amqp` | fixed | — | — | — | — | — | +| `AnsibleIcon` | `ansible` | pair | #1A1918 | #E5E6E7 | 16.99 | 9.98 | ansible/logos community-marks (Black and White variants, CC BY-SA 4.0) | +| `AnthropicIcon` | `anthropic` | pair | #141413 | #FAF9F5 | 17.84 | 11.84 | anthropics/skills | +| `ApifyIcon` | `apify` | fixed | #246DFF | #246DFF | 4.32 | 12.47 | apify.com/resources/brand | +| `ApolloIcon` | `apollo` | pair | #1F1F1E | #F8FF2C | 15.96 | 11.48 | apollo.io | +| `AppwriteIcon` | `appwrite` | mixed | #FD366E | #FD366E | 3.88 | 5.71 | https://appwrite.io/assets | +| `ArcGisIcon` | `arcgis_account` | fixed | #006FDE | #006FDE | 4.69 | 2.57 | Esri's ArcGIS Pro product logo (esri.com/content/dam/esrisites/en-us/common/icons/product-logos/arcgis-pro-64.svg) | +| `AsanaIcon` | `asana` | fixed | #FF584A | #FF584A | 3.01 | 4.01 | asana.com/brand | +| `AssemblyAiIcon` | `assemblyai` | pair | #1D1B16 | #C7C3B2 | 16.65 | 12.47 | assemblyai.com (assemblyai-logo-full-primary.svg / assemblyai-logo-full-secondary.svg) | +| `AttioIcon` | `attio` | pair | #1C1D1F | #FFFFFF | 16.32 | 12.47 | the attio.com header logo (--color-black-100 / --color-white-100) | +| `Auth0Icon` | `auth0` | pair | #232220 | #FFFFFF | 15.38 | 12.47 | auth0.com docs logo light.svg / dark.svg | +| `AutheliaIcon` | `authelia` | fixed | #3F51B4 | #3F51B4 | 6.67 | 1.81 | authelia.com/images/branding/logo-cropped.svg (light stop of the official #3F51B4→#113155 gradient, flattened) | +| `AuthentikIcon` | `authentik` | pair | #FD4B2D | #FFFFFF | 3.27 | 12.47 | goauthentik.io/press | +| `AwsEcrIcon` | `aws_ecr` | fixed | #ED7100 | #ED7100 | 2.92 | 12.47 | the AWS Architecture Icons package (Icon-package_07312026, Arch_Containers/Arch_Amazon-Elastic-Container-Registry) | +| `AwsIcon` | `aws`, `redshift` | pair | #252F3E | #FF9900 | 13.07 | 5.83 | AWS's own logo files (d0.awsstatic.com/logos/powered-by-aws{,-white}.png) | +| `AzureIcon` | `azure` | fixed | — | — | — | — | Microsoft's own logo_azure.svg (learn.microsoft.com/media/logos/logo_azure.svg), whose outer wedges add the #114A8B->#0669BC and #3CCBF4->#2892DF gradients | +| `BambooHrIcon` | `bamboo_hr` | pair | #599D15 | #FFFFFF | 3.25 | 12.47 | bamboohr.com (Encore --brandColor; bamboohr-logo-white.png is the published reversed variant) | +| `BaremetricsIcon` | `baremetrics` | fixed | #5386FF | #5386FF | 3.27 | 3.70 | the mark in baremetrics.com's header logo (baremetrics-logo.svg), the asset this path is taken from | +| `BaserowIcon` | `baserow`, `baserow_table` | fixed | #2BC3F1 | #2BC3F1 | 4.96 | 6.05 | the baserow.io favicon and horizontal logo | +| `BasisTheoryIcon` | `basis_theory` | pair | #1D2032 | #EBEDFF | 15.57 | 10.74 | developers.basistheory.com/img/bt-logo-light.svg and bt-logo-dark.svg, which ship the same mark geometry in the two theme colours | +| `BeamerIcon` | `beamer` | pair | #1C1E21 | #FFFFFF | 16.16 | 12.47 | the getbeamer.com header logo (g#isotype) and their webclip app icon, which sets the same mark in white on #1C1E21 | +| `BigQueryIcon` | `bigquery` | fixed | #34A853 | #34A853 | 3.80 | 7.30 | Google Cloud's official icon library (cloud.google.com/icons, core-products-icons.zip) | +| `BitbucketIcon` | `bitbucket` | pair | #1868DB | #FFFFFF | 5.03 | 12.47 | atlassian.design/foundations/logos (Bitbucket mark, brand and inverse) | +| `BitlyIcon` | `bitly` | fixed | #F36600 | #F36600 | 3.03 | 3.99 | bitly.com/pages/bitly-logo-usage-guidelines-for-media (Bitly-MediaKit glyph_bitly_orange_RGB.svg) | +| `BloggerIcon` | `blogger` | fixed | #F57C00 | #F57C00 | 2.62 | 12.47 | Google's Blogger product logo (gstatic.com/images/branding/productlogos/blogger/v5/192px.svg) | +| `BlueskyIcon` | `bluesky` | pair | #0560FF | #FFFFFF | 4.93 | 12.47 | bsky.social/about/support/branding | +| `BotifyIcon` | `botify` | fixed | #A973FF | #A973FF | 3.09 | 3.91 | botify.com design tokens (--color--surface--purple-05) | +| `BoxIcon` | `box` | pair | #0061D5 | #FFFFFF | 5.54 | 12.47 | box.com (.box-logo-svg fill:#0061d5, reversed to #fff over the dark masthead) | +| `BrevoIcon` | `brevo`, `sendinblue` | fixed | #0B996E | #0B996E | 3.51 | 12.47 | brevo.com's favicon.svg | +| `BrexIcon` | `brex` | pair | #15191E | #FFFFFF | 17.08 | 12.47 | brex.com | +| `BrowserlessIcon` | `browserless` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | browserless.io/favicon.svg | +| `BubbleIcon` | `bubble` | mixed | #0000FF | #0000FF | 8.31 | 5.71 | the logo SVG served on bubble.io/brand; the B is #262626 there, kept as currentColor so the monochrome part follows the app theme | +| `BuildkiteIcon` | `buildkite` | fixed | #30F2A2 | #30F2A2 | 2.04 | 8.52 | buildkite.com/about/brand-assets | +| `BunIcon` | — | fixed | #FBF0DF | #FBF0DF | 6.48 | 12.47 | https://bun.com/logo.svg | +| `ButtondownIcon` | `buttondown` | fixed | #0069FF | #0069FF | 4.55 | 2.65 | https://buttondown.com/brand | +| `CSharpIcon` | — | fixed | #927BE5 | #927BE5 | 7.68 | 12.47 | dotnet/brand logo/language-icons/csharp-72.svg (CC0) | +| `CalcomIcon` | `calcom` | pair | #292929 | #FAFAFA | 14.08 | 11.95 | design.cal.com | +| `CalendlyIcon` | `calendly` | pair | #006BFF | #FFFFFF | 4.47 | 12.47 | Calendly's 2024 External Brand Guidelines and calendly_brand mark_white.svg (media kit on calendly.com/newsroom) | +| `CampaynIcon` | `campayn` | fixed | #008AFF | #008AFF | 3.34 | 12.47 | app.campayn.com/images/campayn/favicons/safari-pinned-tab.svg (colours sampled from android-chrome-512x512.png in the same directory) | +| `CertopusIcon` | `certopus` | fixed | #FF6E30 | #FF6E30 | 12.07 | 12.47 | https://certopus.com/images/logo/logo_circle.svg | +| `ChromaIcon` | `chromadb` | fixed | #FFDE2D | #FFDE2D | 3.65 | 9.35 | Chroma's own logo SVG served by trychroma.com (chroma-wordmark.svg) | +| `CircleCiIcon` | `circleci` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | brand.circleci.com | +| `CiscoIcon` | `cisco` | pair | #00BCEB | #FFFFFF | 2.16 | 12.47 | cisco.com logo SVG and newsroom.cisco.com/logos | +| `ClaudeIcon` | — | fixed | #D97757 | #D97757 | 3.02 | 12.47 | https://claude.ai/favicon.svg (Anthropic's own asset) | +| `ClearbitIcon` | `clearbit` | fixed | #4DB1FD | #4DB1FD | 20.32 | 10.83 | clearbit.com/logo.svg | +| `ClerkIcon` | `clerk` | fixed | #BAB1FF | #BAB1FF | 5.10 | 6.43 | clerk.com/brand-assets (symbol-primary.svg) | +| `ClickhouseIcon` | `clickhouse` | pair | #161616 | #FFFFFF | 17.51 | 12.47 | clickhouse.design/brand/logo-usage (logomark, on-light / on-dark) | +| `ClickupIcon` | `clickup` | fixed | #6647F0 | #6647F0 | 5.46 | 4.12 | clickup.com/brand (v4 Logomark-gradient.svg); the gradient mark is the same on light and dark, and the guidelines say "don't change the color" | +| `CloseIcon` | `close` | fixed | #4EC375 | #4EC375 | 4.77 | 7.39 | close.com/brand (close-logo-2024 mark.svg) | +| `CloudflareIcon` | `cloudflare` | fixed | #FF5F08 | #FF5F08 | 2.95 | 5.83 | the logomark shipped on cloudflare.com, blog.cloudflare.com and workers.cloudflare.com | +| `CloudinaryIcon` | `cloudinary` | pair | #3448C5 | #FFFFFF | 7.06 | 12.47 | cloudinary_logo_for_white_bg.svg and cloudinary_logo_for_black_bg.svg on cloudinary-res.cloudinary.com | +| `CockroachDbIcon` | `cockroachdb` | pair | #6933FF | #FFFFFF | 5.78 | 12.47 | cockroachlabs.com (electric-purple-500, also the CockroachDB docs primaryColor) and the docs light/dark logo pair | +| `CodaIcon` | `coda` | fixed | #F46A54 | #F46A54 | 2.89 | 4.18 | Coda's own app icon, https://cdn.coda.io/icons/png/color/coda-192.png (single-colour mark, no dark variant published) | +| `CodatIcon` | `codat` | fixed | #D1E100 | #D1E100 | 17.31 | 8.60 | codat.io (logo-white.svg glyph outlines, colours from the site palette); framing matches their 300x300 favicon exactly | +| `CohereIcon` | `cohere` | fixed | #355146 | #355146 | 8.41 | 12.47 | https://cohere.com/logo.svg | +| `CoinMarketCapIcon` | `coinmarketcap` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | coinmarketcap.com | +| `CoinbaseIcon` | `coinbase` | pair | #0052FF | — | 5.57 | — | Coinbase's own light/dark logo files (mintcdn.com/coinbase-prod/.../logos/wordmark-light.svg and wordmark-dark.svg, served by docs.cdp.coinbase.com) | +| `ComapeoIcon` | `comapeo_server` | pair | #022199 | #0066FF | 12.09 | 2.58 | the CoMapeo Cloud mark shipped as public/favicon.svg in digidem/comapeo-cloud-app (the server this resource connects to) | +| `ConfluenceIcon` | `confluence` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's @atlaskit/logo (atlassian.design logo library) | +| `ContentfulIcon` | `contentful` | fixed | #1773EB | #1773EB | 4.33 | 9.08 | Contentful's Forma 36 design system (ContentfulLogoIcon) | +| `ContiguityIcon` | `contiguity` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | contiguity.com/assets/icon-white.png and icon-black.png (docs.contiguity.com likewise ships logo/black.svg for light and logo/white.svg for dark) | +| `ConvertKitIcon` | `convertkit` | pair | #1E1E1E | #F2EFE9 | 16.13 | 10.87 | kit.com/brand | +| `CoupaIcon` | `coupa` | pair | #1565C0 | #FFFFFF | 5.56 | 12.47 | the Coupa logo kit linked from coupa.com/company/press-kit, which ships the mark in blue and a white reversed variant | +| `CssIcon` | — | fixed | #663399 | #663399 | 8.13 | 12.47 | github.com/CSS-Next/logo.css (CC0), the official CSS logo endorsed by the W3C CSS WG | +| `CurrencyApiIcon` | `currencyapi` | fixed | #2994FF | #2994FF | 9.13 | 4.67 | currencyapi.com/img/currencyapi_logo_color.svg | +| `DatabricksIcon` | `databricks` | fixed | #FF3621 | #FF3621 | 3.50 | 3.45 | Databricks' own logo asset (databricks.com/sites/default/files/2023-08/databricks-default.png) | +| `DatadogIcon` | `datadog` | pair | #632CA6 | #FFFFFF | 8.32 | 12.47 | datadoghq.com press kit | +| `DatoCmsIcon` | `datocms` | fixed | #FF7751 | #FF7751 | 2.54 | 4.76 | datocms.com/company/brand-assets | +| `DbtIcon` | `dbt_profile` | fixed | #FE6703 | #FE6703 | 2.84 | 4.25 | the dbt Labs brand assets (getdbt.com/brand-guidelines) | +| `DeelIcon` | `deel` | pair | #1B1B1B | #FFFFFF | 16.67 | 12.47 | deel.com's own logo_revamp.svg / logo_revamp_white.svg | +| `DeepInfraIcon` | `deep_infra` | pair | #2A3275 | #4C9CEC | 11.22 | 12.47 | the DeepInfra press-kit logo pack (deepinfra.com/media-center → DEEPINFRA_LOGO_COLOR / DEEPINFRA_LOGO_WHITE) | +| `DeepLIcon` | `deepl` | pair | #0F2B46 | #FFFFFF | 13.97 | 12.47 | DeepL's official logo pack on deepl.com/en/press ("Logo Deep Blue" RGB #0F2B46 and the published "Logo White" reversed variant) | +| `DeepSeekIcon` | `deepseek` | pair | #4D6BFE | #6799FE | 4.19 | 4.49 | deepseek.com design tokens (--ds-color-brand under :root / [data-theme=dark]) | +| `DenoIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Deno Logo Guidelines 2024" asset pack on deno.com/brand | +| `DigitalOceanIcon` | `digitalocean` | fixed | #0080FF | #0080FF | 3.67 | 3.29 | DigitalOcean's official logo kit (DO_Logo_icon_blue.svg, linked from digitalocean.com/press) | +| `DiscordIcon` | `discord`, `discord_webhook` | mixed | #5865F2 | #5865F2 | 4.46 | 5.71 | https://discord.com/branding | +| `DiscourseIcon` | `discourse` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | discourse.org/brand (discourse-icon.svg / discourse-icon-dark.svg) | +| `DocSpringIcon` | `docspring` | fixed | #3C8EE0 | #3C8EE0 | 3.31 | 12.47 | DocSpring's own logo SVG, docspring.com/assets/logo-text-*.svg | +| `DockerIcon` | — | fixed | #2560FF | #2560FF | 4.84 | 2.49 | Docker's official logo kit (docker.com/company/newsroom/media-resources) | +| `DocusignIcon` | `docusign` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.docusign.com/logo: only the Nexus overlap flips per background, Cobalt #4C00FF and Poppy #FF5252 must not be recoloured | +| `DropboxIcon` | `dropbox` | fixed | #0061FE | #0061FE | 4.91 | 2.46 | brand.dropbox.com/logo and the DIG token dig-color__primary__base | +| `DuckDbIcon` | `duckdb` | pair | #1A1A1A | #FFF100 | 16.84 | 10.59 | duckdb.org/design logo package (DuckDB_icon-lightmode.svg / DuckDB_icon-darkmode.svg) | +| `DucklakeIcon` | — | pair | #1A1A1A | #2EAFFF | 16.84 | 5.16 | duckdb.org | +| `DustIcon` | `dust` | fixed | #FE9C1A | #FE9C1A | 4.04 | 10.64 | dust.tt/home/brand-resources (Dust_LogoSquare.svg from their brand kit) | +| `DynatraceIcon` | `dynatrace` | fixed | #1496FF | #1496FF | 10.01 | 7.83 | Dynatrace brand guidelines (live.standards.site/dynatrace, Dynatrace_mark_color.svg) | +| `EdgeDbIcon` | `edgedb` | fixed | #8FAF24 | #8FAF24 | 2.44 | 4.94 | geldata.com (favicon/apple-touch-icon glyph and its ) | +| `EnodeIcon` | `enode` | pair | #5D770D | #E8E8E1 | 4.94 | 10.13 | enode.com/static/favicon.svg | +| `EventbriteIcon` | `eventbrite` | fixed | #FF5E30 | #FF5E30 | 2.95 | 4.10 | the 2025 Eventbrite press kit logos; the brand publishes no reversed variant | +| `ExaIcon` | `exa` | pair | #0143D9 | #FFFFFF | 7.24 | 12.47 | exa.ai/brand (Exa Brand Assets kit, Logomark Blue/White) | +| `FaunadbIcon` | `faunadb` | pair | #3F00A5 | #604BE9 | 11.58 | 2.19 | Fauna's own VS Code extension icons (fauna/fauna-vscode: icons/fauna.svg for light themes, icons/fauna-light.svg for dark) | +| `FigmaIcon` | `figma` | fixed | #24CB71 | #24CB71 | 4.42 | 5.85 | static.figma.com/app/icon/2/favicon.svg (2025 brand refresh) | +| `FirebaseIcon` | `firebase` | fixed | #FF9100 | #FF9100 | 4.58 | 7.81 | firebase.google.com/brand-guidelines (Logomark_Full Color.svg in firebase-brand-assets.zip) | +| `FlyIcon` | `fly` | pair | #24175B | #FFFFFF | 15.07 | 12.47 | fly.io | +| `FormstackIcon` | `formstack` | fixed | #21B573 | #21B573 | 2.56 | 4.70 | the brand guide at formstack.com/press-kit | +| `FoxentryIcon` | `foxentry` | fixed | #E74600 | #E74600 | 5.09 | 4.14 | foxentry.com/assets/img/logo-foxentry-symbol.svg | +| `FreshdeskIcon` | `freshdesk` | fixed | #20A849 | #20A849 | 3.01 | 12.47 | Freshworks' own product-logo asset (freshdesk-dew.svg, used on freshworks.com/apps) | +| `FrontAppIcon` | `frontapp` | fixed | #A857F1 | #A857F1 | 3.83 | 3.15 | the logo mark front.com ships inline on its own pages; the mark keeps this purple on both light and dark backgrounds | +| `FunkwhaleIcon` | `funkwhale` | mixed | #009FE3 | #009FE3 | 10.69 | 5.71 | www.funkwhale.audio/logos (theme/images/icon.svg) | +| `GSheetsIcon` | `gsheets` | fixed | #009954 | #009954 | 3.57 | 12.47 | Google product logo sheets_2026q3 (gstatic productlogos, used on workspace.google.com/products/sheets) | +| `GcalIcon` | `gcal` | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | Google's own Calendar 2026 product logo, https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg (paths verbatim) | +| `GdocsIcon` | `gdocs` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | Google's own Docs product icon (gstatic.com/images/branding/productlogos/docs_2026/v2/web/192px.svg, served on workspace.google.com/products/docs) | +| `GdriveIcon` | `gdrive` | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg, Google's own product-logo CDN; paths and gradient stops are verbatim | +| `GhostCmsIcon` | `ghostcms` | pair | #15171A | #FFFFFF | 17.38 | 12.47 | docs.ghost.org | +| `GiphyIcon` | `giphy` | fixed | #FFF35C | #FFF35C | 4.76 | 10.83 | GIPHY's own app icon (giphy.com/static/img/icons/apple-touch-icon-180px.png) | +| `GitBookIcon` | `gitbook` | pair | #181C1F | #F2F7F7 | 16.59 | 11.54 | the GitBook-icon-dark / GitBook-icon-light downloads on gitbook.gitbook.io/brand-assets, matching the live gitbook.com favicon | +| `GitIcon` | `git_repository`, `git` | fixed | #F03C2E | #F03C2E | 3.77 | 3.20 | git-scm.com/community/logos (Git-Icon-1788C.svg, logo by Jason Long, CC BY 3.0) | +| `GithubIcon` | `github` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.github.com/foundations/logo | +| `GitlabIcon` | `gitlab` | mixed | #FC6D26 | #FC6D26 | 4.01 | 6.18 | https://design.gitlab.com/brand-design/color (Orange 03p/02p/01p, "colors from our core logo") | +| `GmailIcon` | `gmail` | mixed | #4285F4 | #4285F4 | 5.61 | 7.30 | gstatic.com/images/branding/product/2x/gmail_2020q4_48dp.png | +| `GoogleAiIcon` | `googleai` | fixed | #217BFE | #217BFE | 3.81 | 5.44 | Google's standard Gemini product icon (gstatic.com/images/branding/productlogos/gemini/v1/192px.svg) | +| `GoogleCalendarIcon` | — | fixed | #BBE2FF | #BBE2FF | 3.40 | 12.47 | the Google Calendar 2026 product icon, taken verbatim from https://www.gstatic.com/images/branding/productlogos/calendar_2026/v2/web/192px.svg | +| `GoogleCloudIcon` | `gcloud`, `gcp_service_account` | fixed | #EA4335 | #EA4335 | 3.80 | 7.30 | Google's own product logo asset https://www.gstatic.com/images/branding/product/2x/google_cloud_64dp.png | +| `GoogleDriveIcon` | — | fixed | #B43333 | #B43333 | 5.87 | 10.05 | https://www.gstatic.com/images/branding/productlogos/drive_2026/v2/web/192px.svg (Drive 2026 mark, copied verbatim) | +| `GoogleFormsIcon` | `gforms` | fixed | #969DFF | #969DFF | 5.99 | 12.47 | Google's own Forms product icon at www.gstatic.com/images/branding/productlogos/forms_2026/v2/web/192px.svg | +| `GoogleIcon` | `google`, `gworkspace` | mixed | #4285F4 | #4285F4 | 3.88 | 7.30 | the G mark Google serves in accounts.google.com/gsi/client | +| `GorgiasIcon` | `gorgias` | pair | #000000 | #FFF9F4 | 20.32 | 11.94 | gorgias.com/about-us/style, which ships the symbol as a "Dark"/"Light" pair | +| `GraphqlIcon` | `graphql` | pair | #E10098 | #FFFFFF | 4.37 | 12.47 | graphql.org | +| `GreipIcon` | `greip` | pair | #141C27 | #FFFFFF | 16.59 | 12.47 | docs.greip.io | +| `GristIcon` | `grist` | fixed | #16B378 | #16B378 | 2.62 | 8.25 | getgrist.com/trademark/assets/ | +| `GroqIcon` | `groqai`, `groq` | fixed | #F43E01 | #F43E01 | 3.67 | 12.47 | https://groq.com/favicon.svg | +| `HackernewsIcon` | `hackernews` | fixed | #FF6600 | #FF6600 | 2.84 | 12.47 | news.ycombinator.com/y18.svg | +| `HoldedIcon` | `holded` | fixed | #FD454D | #FD454D | 3.31 | 3.64 | cdn.holded.com/assets/img/brand/holded-logo.svg | +| `HoneybadgerIcon` | `honeybadger` | fixed | #EA5937 | #EA5937 | 3.40 | 3.55 | honeybadger.io/favicon.svg | +| `HtmlIcon` | — | fixed | #E44D26 | #E44D26 | 3.77 | 12.47 | the W3C HTML5 logo, w3.org/html/logo (downloads/HTML5_Logo.svg) | +| `HubspotIcon` | `hubspot` | pair | #FF2F00 | #FFFFFF | 3.59 | 12.47 | hubspot.com | +| `IfsIcon` | `ifs_cloud_oidc` | fixed | #72C9F8 | #72C9F8 | 6.03 | 6.79 | the IFS symbol on ifs.com | +| `IftttIcon` | `ifttt` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | ifttt.com | +| `InkeepIcon` | `inkeep` | fixed | #D5E5FF | #D5E5FF | 2.45 | 9.79 | Inkeep's brand page "Icon Core" (https://inkeep.com/brand) | +| `IntercomIcon` | `intercom` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | intercom.com | +| `IpinfoIcon` | `ipinfo` | pair | #3091CF | #FFFFFF | 3.34 | 12.47 | ipinfo.io logo-positive.svg and logo-negative.svg | +| `JavaIcon` | — | fixed | #007396 | #007396 | 5.22 | 4.93 | Oracle's Java Branding and Licensing Guidelines v21 (oracle.com/a/ocom/docs/java-licensing-logo-guidelines-1908204.pdf) | +| `JavaScriptIcon` | — | fixed | #F7DF1E | #F7DF1E | 20.32 | 9.22 | js.svg in github.com/voodootikigod/logo.js, the origin of the JavaScript logo | +| `JiraIcon` | `jira` | fixed | #1868DB | #1868DB | 5.03 | 12.47 | Atlassian's official Jira logo pack (atlassian.design/foundations/logos) | +| `JoomlaIcon` | `joomla` | fixed | #7AC143 | #7AC143 | 3.58 | 6.23 | the official logo at cdn.joomla.org/images/joomla-colours-logo.svg | +| `JotformIcon` | `jotform` | pair | #0A1551 | #FFFFFF | 16.40 | 12.47 | jotform.com footer logomark (#jotform-logomark-fourth is filled with --jf-logo-img: #0A1551 light, #fff dark) | +| `JsonIcon` | — | fixed | #F9A825 | #F9A825 | 1.91 | 6.33 | Material Design Yellow 800 (api.flutter.dev Colors.yellow[800]); glyph is Google's Material Symbols "data_object" | +| `JumpCloudIcon` | `jumpcloud` | pair | #002B49 | #F7F7FB | 14.09 | 11.67 | jumpcloud.com/press (Ocean Blue / White Smoke); White Smoke is the brand's own reversed logo for dark backgrounds | +| `KafkaIcon` | `kafka` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | apache/kafka | +| `KanidmIcon` | `kanidm` | fixed | #B1DEF4 | #B1DEF4 | 20.32 | 12.47 | artwork/logo-square.svg in github.com/kanidm/kanidm (full palette: #FF6600 #803300 #D45500 #2A3455 #B1B3B8 #CCCCCC) | +| `KeycloakIcon` | `keycloak` | fixed | #00B8E3 | #00B8E3 | 8.18 | 10.65 | keycloak.org's own mark, https://www.keycloak.org/resources/images/icon.svg (cyan #00B8E3/#33C6E9/#008AAA over greys #4D4D4D–#EDEDED, single theme) | +| `KlaviyoIcon` | `klaviyo` | pair | #1D1E20 | #FFFFFF | 16.14 | 12.47 | klaviyo.com --color-core-charcoal; the flag mark is the standalone logomark the site header collapses to, and the shape of klaviyo.com/icons/icon-512x512.png | +| `KoboToolboxIcon` | `kobotoolbox` | fixed | #2095F3 | #2095F3 | 3.05 | 3.95 | the kobotoolbox.org header logo and $kobo-blue in kobotoolbox/kpi jsapp/scss/colors.scss | +| `KustomerIcon` | `kustomer` | fixed | #FBEC2A | #FBEC2A | 14.08 | 12.47 | kustomer.com/images/kustomer/Kusty.svg | +| `LangfuseIcon` | `langfuse` | fixed | #FF5D5F | #FF5D5F | 2.91 | 4.47 | langfuse.com/brand "Icon - Color (SVG)", used unmodified | +| `LessIcon` | — | pair | #274F82 | #FFFFFF | 8.04 | 12.47 | github.com/less/logo (MIT) | +| `LineIcon` | `line` | fixed | #06C755 | #06C755 | 2.18 | 5.53 | LINE's official brand icon asset (line.me/en/logo) | +| `LinearIcon` | `linear` | pair | #222326 | #F4F5F8 | 15.20 | 11.44 | linear.app/brand | +| `LinkdingIcon` | — | pair | #5856E0 | #ADABF7 | 5.32 | 5.91 | sissbruecker/linkding | +| `LinkedinIcon` | `linkedin` | mixed | #0A66C2 | #0A66C2 | 5.50 | 12.47 | the official inbug SVGs embedded in brand.linkedin.com/in-logo | +| `LinodeIcon` | `linode` | fixed | #004B16 | #004B16 | 10.07 | 4.54 | Linode's own packages/manager/src/assets/logo/logo.svg in linode/manager @3e53c92, the last revision before the Akamai rebrand dropped it | +| `LumaAiIcon` | `lumaai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | lumalabs.ai (favicon-black.ico on light, favicon-white.ico on dark) | +| `MSSqlServerIcon` | — | fixed | #0094F0 | #0094F0 | 10.54 | 12.47 | learn.microsoft.com/en-us/azure/architecture/icons — Microsoft's anchor blue, a stop in its own SQL Server SVG and throughout the set's Fluent gradients | +| `MSTeamsIcon` | — | fixed | #A98AFF | #A98AFF | 12.96 | 12.47 | Microsoft's Teams-Icon-FY26 asset (cdn-dynmedia-1.microsoft.com, served on microsoft.com/microsoft-teams); every gradient stop here is verbatim from it | +| `MagentoIcon` | `magento` | fixed | #F26322 | #F26322 | 3.09 | 3.91 | Magento's own logo asset, magento2 lib/web/images/logo.svg | +| `MailchimpIcon` | `mailchimp` | fixed | #241C15 | #241C15 | 16.23 | 10.74 | mailchimp.com/about/brand-assets | +| `MailerLiteIcon` | `mailerlite` | fixed | #09C269 | #09C269 | 2.27 | 5.31 | mailerlite.com/brand-assets | +| `MailgunIcon` | `mailgun` | fixed | #F04126 | #F04126 | 3.70 | 12.47 | mailgun.com's own logo-mailgun-icon.svg | +| `MandrillIcon` | `mandrill` | pair | #241C15 | #FFFFFF | 16.23 | 12.47 | mailchimp.com/about/brand-assets and Mandrill's own mandrillapp.com/img/navigation/freddie.svg | +| `MapboxIcon` | `mapbox` | pair | #0E1012 | #FFFFFF | 18.45 | 12.47 | mapbox.com | +| `MarkdownIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | dcurtis/markdown-mark (public domain) | +| `MastodonIcon` | `mastodon` | mixed | #6364FF | #6364FF | 7.07 | 12.47 | https://joinmastodon.org/branding | +| `MatrixIcon` | `matrix` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | matrix.org/branding | +| `MatteroomIcon` | `matteroom` | fixed | #134A81 | #134A81 | 8.74 | 12.47 | the MATTEROOM logomark vector at login.matteroom.com/images/login_logo.svg; square tile proportions taken from their own app icon at matteroom.com/favicon.ico | +| `MauticIcon` | `mautic` | pair | #4E5E9E | #FFFFFF | 5.94 | 12.47 | mautic.org/about/brand-logos-graphics (Mautic_Logo_LB.svg / Mautic_Logo_DB.svg); the "M" stays Sunglow #FDB933 in both, as the trademark policy requires the mark in its exact published form without alteration in colour | +| `McpIcon` | `mcp` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | modelcontextprotocol/modelcontextprotocol | +| `MediumIcon` | `medium` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | medium.design | +| `MeteosourceIcon` | `meteosource` | fixed | #FAD961 | #FAD961 | 2.87 | 9.01 | the logo in the meteosource.com site header (no brand page published) | +| `MezmoIcon` | `mezmo` | pair | #0A090C | #E6E6E5 | 19.22 | 9.99 | mezmo.com nav mark and docs.mezmo.com logo/light.png + logo/dark.png | +| `MicrosoftIcon` | `microsoft` | mixed | #F25022 | #F25022 | 3.88 | 7.24 | the official logo asset linked from Microsoft's logo third-party usage guidance | +| `MiroIcon` | `miro` | fixed | #FFDD33 | #FFDD33 | 16.46 | 9.29 | the Miro logo on miro.com | +| `MistralIcon` | `mistral` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | mistral.ai/favicon.svg (mid-band of the #FFAF01 -> #C4001D ramp); drawn here in currentColor, the monochrome variant mistral.ai/brand ships | +| `MixpanelIcon` | `mixpanel` | pair | #7856FF | #FFFFFF | 4.44 | 12.47 | brand.mixpanel.com/logo and /color (Purple 100); mixpanel.com ships the same pair as its light/dark favicons | +| `MollieIcon` | `mollie` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Mollie's app icon (my.mollie.com/assets/images/favicons/apple-touch-icon-180x180.png): a full-bleed disc with the lowercase m knocked out | +| `MondayIcon` | `monday` | fixed | #FB275D | #FB275D | 3.66 | 8.25 | monday.com's official logo pack (brand-monday.com/logo) | +| `MongodbIcon` | `mongodb` | pair | #00684A | #00ED64 | 6.60 | 7.90 | MongoDB brand resources and their LeafyGreen palette | +| `MotimateIcon` | `motimate` | fixed | #2DC89C | #2DC89C | 2.06 | 5.85 | motimateapp.com theme assets | +| `MqttIcon` | `mqtt` | pair | #660066 | #FFFFFF | 11.57 | 12.47 | mqtt/mqttorg-graphics | +| `Mysql` | `mysql` | inherits | #718096 | #A9B0BA | 3.88 | 5.71 | — | +| `NatsIcon` | `nats` | pair | #375C93 | #27AAE1 | 6.52 | 4.71 | cncf/artwork | +| `NeonDbIcon` | `neondb` | pair | #37C38F | #34D59A | 2.17 | 6.61 | neon.com/brand (neon-logomark-light-color.svg / neon-logomark-dark-color.svg) | +| `NetBoxIcon` | `netbox` | pair | #001423 | #FFFFFF | 18.07 | 12.47 | theme, so the second path carries its own fill- utilities | +| `NetlifyIcon` | `netlify` | pair | #05BDBA | #32E6E2 | 10.06 | 12.47 | netlify.com/brand (netlify-logo-monogram.zip, full-colour lightmode/darkmode) | +| `NetsuiteIcon` | `netsuite` | fixed | #BACCDB | #BACCDB | 7.71 | 7.57 | — | +| `NewsApiIcon` | `newsapi` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | newsapi.org | +| `NextcloudIcon` | `ocs`, `nextcloud` | pair | #0082C9 | #FFFFFF | 4.03 | 12.47 | nextcloud.com | +| `NocoDbIcon` | `nocodb` | fixed | #4351E8 | #4351E8 | 11.12 | 3.30 | nocodb.com's own Logo.svg / favicon | +| `NotionIcon` | `notion` | fixed | #FFFFFF | #FFFFFF | 20.32 | 12.47 | Notion's own app icon (notion.com/front-static/logo-ios.png) | +| `NuIcon` | — | fixed | #4D9B05 | #4D9B05 | 3.38 | 3.57 | nushell/vscode-nushell-lang assets/nu.svg | +| `OdkIcon` | `odk` | fixed | #3E77B4 | #3E77B4 | 6.37 | 2.67 | ODK brand assets (getodk.org/legal/brand/) | +| `OktaIcon` | `okta` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | okta.com | +| `OneSignalIcon` | `onesignal` | pair | #051B2C | #FFFFFF | 16.94 | 12.47 | OneSignal's official media kit (OneSignal-Logomark.svg / OneSignal-Logomark-White.svg), matching the prefers-color-scheme pair in their own onesignal.com/favicon.svg | +| `OpenRouterIcon` | `openrouter` | pair | #7624F4 | #C8FF00 | 6.10 | 10.55 | openrouter.ai/brand/v2/openrouter-glyph-{light,dark}.svg | +| `OpenWeatherIcon` | `openweather` | pair | #EA6D4A | — | 2.99 | — | openweather.co.uk/brand_guidelines | +| `OpenaiIcon` | `openai` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | openai.com/brand (Blossom_Light.svg / Blossom_Dark.svg) | +| `OracleDBIcon` | `oracledb` | fixed | #C74634 | #C74634 | 4.67 | 2.59 | Oracle's own logo SVG at https://www.oracle.com/a/ocom/img/oracle-logo.svg | +| `OutreachIcon` | `outreach` | pair | #5951FF | #FFFFFF | 5.02 | 12.47 | outreach.ai | +| `PHPIcon` | — | fixed | #AEB2D5 | #AEB2D5 | 20.32 | 12.47 | php.net/images/logos/new-php-logo.svg (php.net/download-logos.php) | +| `PagerDutyIcon` | `pagerduty` | pair | #048A24 | #FFFFFF | 4.35 | 12.47 | pagerduty.com/brand "P icon" pack (P-GreenRGB.svg / P-WhiteRGB.svg) | +| `PandaDocIcon` | `pandadoc` | fixed | #248567 | #248567 | 4.39 | 12.47 | the PandaDoc logo shipped on pandadoc.com (header logo SVG and favicon); white monogram on the green tile in both themes | +| `PaychexIcon` | `paychex` | fixed | #004B8D | #004B8D | 8.50 | **1.42** | paychex.com's own logo SVG (themes/custom/paychex2/images/svg/logo-paychex.svg, .st0) | +| `PaylocityIcon` | `paylocity` | fixed | #ED2024 | #ED2024 | 4.20 | 12.47 | paylocity.com design-system CSS (.styleBGBrandGradient) | +| `PaypalIcon` | `paypal` | fixed | #002991 | #002991 | 11.77 | 6.93 | PayPal's own paypal-mark-color_new.svg (site header logo on paypal.com) | +| `PersonaIcon` | `persona` | fixed | #7379FD | #7379FD | 3.45 | 3.49 | https://withpersona.com/favicon.svg (Persona, identity verification) | +| `PersonioIcon` | `personio` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | personio.design/brand/how-we-look/logo | +| `PhraseIcon` | `phrase` | pair | #181818 | #FFFFFF | 17.18 | 12.47 | Logo_primary.svg and Logo_black_background.svg on phrase.com/brand | +| `PineconeIcon` | `pinecone` | pair | #201D1E | #FFFFFF | 16.18 | 12.47 | pinecone.io/newsroom/media-kit | +| `PinterestIcon` | `pinterest` | fixed | #E60023 | #E60023 | 4.63 | 2.61 | Pinterest Gestalt tokens (color.icon.brand.primary = red.pushpin.450, identical in sema-color-light and sema-color-dark) | +| `PipedriveIcon` | `pipedrive` | fixed | #017737 | #017737 | 5.50 | 12.47 | pipedrive.com logo token --pd-puco-global-color-green-500 | +| `PlanetScaleIcon` | `planetscale` | pair | #1A1A1A | #FAFAFA | 16.84 | 11.95 | planetscale.com | +| `PocketIdIcon` | `pocketid` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | pocket-id.org header logo (fill isDark ? #ffffff : #000000) and pocket-id/pocket-id frontend/src/lib/components/logo.svelte | +| `PostgresIcon` | `postgresql` | mixed | #336791 | #336791 | 20.32 | 12.47 | the official 3-colour Slonik SVG on wiki.postgresql.org/wiki/Logo | +| `PostmarkIcon` | `postmark` | fixed | #FFDE00 | #FFDE00 | 20.32 | 9.33 | postmarkapp.com/images/logo-stamp-simple.svg | +| `PowershellIcon` | — | fixed | #00FF18 | #00FF18 | 20.32 | 12.47 | github.com/PowerShell/PowerShell/blob/master/assets/ps_black_64.svg | +| `PusherIcon` | `pusher` | pair | #300D4F | #FFFFFF | 15.68 | 12.47 | pusher.com media kit (Pusher logo primary.png / Pusher logo secondary.png) | +| `PushoverIcon` | `pushover` | fixed | #249DF1 | #249DF1 | 2.83 | 12.47 | support.pushover.net/i63-pushover-logos-and-usage | +| `QoveryIcon` | `qovery` | fixed | #642DFF | #642DFF | 6.05 | 2.00 | qovery.com/logos/qovery-logo-black.svg | +| `QuickbooksIcon` | `quickbooks` | fixed | #2CA01C | #2CA01C | 3.30 | 3.65 | the QuickBooks logo SVG on intuit.com's press room | +| `RIcon` | — | fixed | #276DC3 | #276DC3 | 6.46 | 7.89 | r-project.org/logo (gradient stops copied from the authoritative Rlogo.svg) | +| `RaindropIcon` | `raindrop` | fixed | #1988E0 | #1988E0 | 5.33 | 12.47 | app.raindrop.io/assets/icon_raw.svg and raindrop.io icon_128.png | +| `ReactIcon` | — | pair | #087EA4 | #58C4DC | 4.48 | 6.14 | react.dev brand menu (images/brand/logo_light.svg, logo_dark.svg) | +| `ReadmeIcon` | `readme` | pair | #213AFF | #FFFFFF | 6.53 | 12.47 | readme.com's own prefers-color-scheme favicon pair (favicon-213aff.ico / favicon-ffffff.ico) | +| `ReadwiseIcon` | `readwise` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | readwise.io's logo-standalone-dark.svg (light) and logo-standalone-white.svg (dark) | +| `RecraftIcon` | `recraft` | fixed | #000000 | #000000 | 20.32 | 12.47 | Recraft's press-kit "Icon White" mark (https://www.recraft.ai/press-releases) | +| `RedditIcon` | `reddit` | fixed | #FF6600 | #FF6600 | 20.32 | 12.47 | redditinc.com/brand ("a stylized Snoo head contained within an OrangeRed (#FF4500) conversation bubble") | +| `RenderIcon` | `render` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | render.com | +| `ReplicateIcon` | `replicate` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | replicate.com header logo (glyph is currentColor; site CSS sets #000, and #FFF under .dark) | +| `ResendIcon` | `resend` | pair | #000000 | #FDFDFD | 20.32 | 12.26 | cdn.resend.com/brand/resend-icon-black.svg and resend-icon-white.svg | +| `RingCentralIcon` | `ringcentral` | pair | #FF7A00 | #FFFFFF | 2.53 | 12.47 | assets.ringcentral.com/us/brand-library/logos/ringcentral-logo.zip (RingCentral logo fullcolor.svg / RingCentral logo white.svg) | +| `RocketChatIcon` | `rocketchat` | fixed | #F5455C | #F5455C | 3.45 | 3.50 | Rocket.Chat brand colours (docs.rocket.chat/v1/docs/colors), the primary red of their logo | +| `RssIcon` | `rss` | fixed | #FFA500 | #FFA500 | 1.91 | 12.47 | Mozilla's feed icon guidelines (mozilla.org/en-US/foundation/feed-icon-guidelines/), which fix no exact hex | +| `RubyIcon` | — | fixed | #FB7655 | #FB7655 | 10.63 | 12.47 | the official logo kit at ruby-lang.org/en/about/logo | +| `RunPodIcon` | `runpod` | pair | #5D29F0 | #FFFFFF | 6.68 | 12.47 | runpod.io/brandkit | +| `RustIcon` | — | pair | #000000 | #FFFFFF | 20.32 | 12.47 | rust-lang/rust-artwork | +| `S3Icon` | `s3` | fixed | #7AA116 | #7AA116 | 2.93 | 12.47 | AWS Architecture Icons (Icon-package_07312026, Arch_Storage/64/Arch_Amazon-Simple-Storage-Service_64.svg) | +| `SageIcon` | `sage_intacct` | pair | #000000 | #00D639 | 20.32 | 6.34 | @sage/design-tokens --logo-sage-bg-default | +| `SalesflareIcon` | `salesflare` | fixed | #0053FF | #0053FF | 5.52 | 2.19 | salesflare.com's own `--color--major-blue` design token | +| `SalesforceIcon` | `salesforce` | fixed | #00B3FF | #00B3FF | 2.29 | 5.28 | brand.salesforce.com/brand/color | +| `SassIcon` | — | fixed | #CC6699 | #CC6699 | 3.43 | 12.47 | sass-lang.com's own style guide token --sl-color--hopbush (assets/dist/css/sass.css) | +| `SegmentIcon` | `segment` | fixed | #52BD94 | #52BD94 | 2.24 | 5.38 | Segment's own app favicon (app.segment.com) and Evergreen green500 #52BD95 | +| `SendflakeIcon` | `snowflake` | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SendgridIcon` | `sendgrid` | fixed | #00B3E3 | #00B3E3 | 3.81 | 8.57 | styleguide.sendgrid.com/colors.html | +| `SensorTowerIcon` | `sensortower` | fixed | #00CFB8 | #00CFB8 | 1.91 | 12.47 | sensortower.com/favicon.svg, copied verbatim | +| `SentryIcon` | `sentry` | pair | #181225 | #FFFFFF | 17.64 | 12.47 | sentry.io/branding logo generator (Dark/Light themes, "Invert in dark mode") | +| `ServiceNowIcon` | `servicenow` | fixed | #62D84E | #62D84E | 1.77 | 6.80 | servicenow.com/company/servicenow-logo.html (servicenow-logo-icon.svg) | +| `ShopifyIcon` | `shopify` | fixed | #95BF47 | #95BF47 | 3.75 | 12.47 | shopify.com/brand-assets (shopify-logo-shopping-bag-full-color.svg: #95BF47/#5E8E3E/#fff) | +| `ShortcutIcon` | `shortcut` | pair | #494BCB | #797ADE | 6.45 | 3.36 | shortcut.com/branding (mark-default.svg; the reversed lockup uses #797ADE on dark) | +| `ShutterstockIcon` | `shutterstock` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | brand.shutterstock.com | +| `SigNozIcon` | `signoz` | fixed | #FF5E19 | #FF5E19 | 3.63 | 12.47 | signoz.io/img/SigNozLogo-orange.svg | +| `Slack` | `slack` | mixed | #E01E5A | #E01E5A | 4.51 | 6.52 | slack.com's own nav logo (a.slack-edge.com/38f0e7c/marketing/img/nav/logo.svg, linked from slack.com/media-kit) | +| `SmartsheetIcon` | `smartsheet` | pair | #031C59 | #FFFFFF | 15.46 | 12.47 | brandguides.brandfolder.com/smartsheet-visual-guide/basics | +| `SnowflakeIcon` | — | pair | #29B5E8 | — | 2.29 | — | snowflake.com/brand-guidelines | +| `SpeechifyIcon` | `speechify` | pair | #2F43FA | #FFFFFF | 6.15 | 12.47 | the Speechify brand kit (speechify.com/brand-kit, Logomark_blue.svg and Logomark_white.svg) | +| `SplitwiseIcon` | `splitwise` | pair | #1CC29F | — | 2.19 | — | splitwise.com/press (sw.svg / sw-wide.svg / bg-primary.svg) | +| `SpotifyIcon` | `spotify` | pair | #1ED760 | #FFFFFF | 1.86 | 12.47 | developer.spotify.com/documentation/design (2024 Primary Logo icon pack) | +| `SquareIcon` | `square` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Square_Logo_2025 in squareup.com/us/en/press/logo | +| `StraleIcon` | `strale` | pair | #0D0D0E | #F2F2F3 | 18.80 | 11.15 | strale.dev favicon.svg and the site's own --foreground token | +| `StravaIcon` | `strava` | fixed | #FC5200 | #FC5200 | 3.20 | 3.77 | developers.strava.com/guidelines (Strava API logo pack, orange SVGs) | +| `StripeIcon` | `stripe` | fixed | #533AFD | #533AFD | 5.99 | 12.47 | Stripe's own favicon.svg and Stripe_logo_kit.zip (stripe.com/newsroom/brand-assets) | +| `SupabaseIcon` | `supabase` | fixed | #3ECF8E | #3ECF8E | 3.75 | 6.25 | supabase.com/brand-assets | +| `SurrealdbIcon` | `surrealdb` | mixed | #D255FE | #D255FE | 7.33 | 5.71 | surrealdb.com/brand | +| `SvelteIcon` | — | fixed | #FF3E00 | #FF3E00 | 3.42 | 12.47 | sveltejs/branding (svelte-logo.svg, white cutout #fff) | +| `TallyIcon` | `tally` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | the "Tally Icon - Black" / "Tally Icon - White" files in the icon pack on tally.so/help/press-kit, matching the live tally.so/favicon.svg | +| `TaskadeIcon` | `taskade` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | taskade.com/press (Mascot Mark light = agent_taskade.svg, Genesis Icon dark = taskade-icon-dark.svg) | +| `TelegramIcon` | `telegram` | fixed | #2AABEE | #2AABEE | 2.92 | 12.47 | Telegram's press-kit Logo.svg (telegram.org/press) | +| `TelnyxIcon` | `telnyx` | pair | #000000 | #00E3AA | 20.32 | 12.47 | telnyx.com | +| `TerraIcon` | `terra` | fixed | #008AFF | #008AFF | 20.32 | 10.43 | tryterra.co/providers/terra_icon.svg, the only vector square mark Terra ships (the site logo is a "TERRA API" wordmark, the favicon a raster .ico) | +| `TheirStackIcon` | `their_stack` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | theirstack.com/en/docs/brand, which lists both as core brand colours | +| `ThreadsIcon` | `threads` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | Meta's Threads Brand Resource Center logo pack (meta.com/brand/resources/threads) | +| `TodoistIcon` | `todoist` | fixed | #E44232 | #E44232 | 3.97 | 3.04 | Todoist Brand Guidelines (doist.com/brand-assets/todoist-logo.zip), "Red — the primary brand color for Todoist" | +| `TogetherAiIcon` | `togetherai` | fixed | #EF2CC1 | #EF2CC1 | 3.50 | 6.46 | together.ai's brand page (https://www.together.ai/brand) | +| `TogglIcon` | `toggl` | pair | #2C1138 | #E57CD8 | 16.33 | 4.87 | Toggl Track media toolkit (toggl.com/track/media-toolkit, icon-dark-purple.svg / icon-pink.svg) | +| `TomorrowIoIcon` | `tomorrow` | fixed | #004CF8 | #004CF8 | 6.00 | 12.47 | tomorrow.io's own design tokens (--color-logo-blue in site-frame.min.css, matching the header lockup SVG and logo-490.png) | +| `TrelloIcon` | `trello` | fixed | #1558BC | #1558BC | 6.44 | 12.47 | Atlassian Design logo library (atlassian.design/foundations/logos → trello_app.zip, Trello_icon.svg) | +| `TripadvisorIcon` | `tripadvisor` | fixed | #002B11 | #002B11 | 15.01 | **1.24** | 2025 Tripadvisor Brand Guidelines for Partners, tripadvisor.mediaroom.com | +| `TursoIcon` | `turso` | pair | #183134 | #FFFFFF | 13.30 | 12.47 | turso.tech/brand (Dark Teal and white logomark variants) | +| `TwilioIcon` | `twilio` | fixed | #F22F46 | #F22F46 | 3.86 | 3.13 | twilio.com (mask-icon color, favicon and apple-touch-icon artwork) | +| `TwitchIcon` | `twitch` | fixed | #9146FF | #9146FF | 4.49 | 2.69 | brand.twitch.com | +| `TwitterIcon` | `twitter` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | about.x.com | +| `TypeformIcon` | `typeform` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | typeform.com/brand | +| `UltravoxIcon` | `ultravox` | fixed | #BB3B57 | #BB3B57 | 6.67 | 7.65 | the ultravox.ai favicon (framerusercontent.com/images/hzAEdihxJ11mv3l4trNh2WprE.svg) | +| `VectaraIcon` | `vectara` | fixed | #7E00FF | #7E00FF | 7.00 | 9.80 | — | +| `VercelIcon` | `vercel` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | vercel.com | +| `VismaIcon` | `visma` | pair | #131313 | #FFFFFF | 17.98 | 12.47 | design.visma.com/logo (VA symbol from the official Visma Logopack) | +| `VueIcon` | — | fixed | #42B883 | #42B883 | 8.97 | 5.00 | vuejs/art logo.svg | +| `WebflowIcon` | `webflow` | fixed | #146EF5 | #146EF5 | 4.44 | 2.72 | brand.webflow.com/brand-assets | +| `WhatsappBusinessIcon` | `whatsapp_business` | fixed | #25D366 | #25D366 | 1.92 | 6.29 | WhatsApp's Digital_Glyph_Green_RGB_2026.svg, shipped by whatsapp.com/business (→ whatsappbusiness.com) | +| `WizIcon` | `wiz` | pair | #0254EC | #FFFFFF | 5.84 | 12.47 | wiz.io/press media kit logo pack (WizLogo_Blue_Vector.svg / WizLogo_White_Vector.svg) | +| `WooCommerceIcon` | `woocommerce` | pair | #873EFF | #FFFFFF | 4.88 | 12.47 | the Woo logo pack at woocommerce.com/brand-and-logo-guidelines (Woo_logo_color.svg and Woo_logo_white.svg) | +| `WordpressIcon` | `wordpress` | pair | #32373C | #FFFFFF | 11.63 | 12.47 | wordpress.org/about/logos/ | +| `XataIcon` | `xata` | fixed | #8468F6 | #8468F6 | 3.84 | 3.14 | xata.io/brand (logo-symbol.svg) | +| `XeroIcon` | `xero` | fixed | #13B5EA | #13B5EA | 2.30 | 12.47 | xero.com favicon.svg and the site header logo (Xero__LogoPath fill) | +| `YamlIcon` | — | mixed | #CB171E | #CB171E | 5.52 | 5.71 | yaml.org's own assets/favicon.svg and assets/logo.png; the Y, M and L carry no fill in YAML's SVG, so they take the surrounding text colour | +| `YelpIcon` | `yelp` | fixed | #FF1A1A | #FF1A1A | 3.75 | 3.22 | yelp.com/brand (burst_red.svg and the official logo kit) | +| `YnabIcon` | `ynab` | pair | #3B5EDA | #FEF9E6 | 5.35 | 11.82 | ynab.com press kit tree logo (Tree Logo Blurple.svg / Tree Logo Buttermilk.svg — the buttermilk reverse is what ynab.com itself uses on its dark footer) | +| `YoutubeIcon` | `youtube` | fixed | #FF0033 | #FF0033 | 3.83 | 12.47 | brand.youtube/color (YouTube Red, updated from #FF0000) | +| `ZammadIcon` | `zammad` | fixed | #CD2015 | #CD2015 | 7.62 | 9.73 | zammad.com favicon-32x32.svg | +| `ZendeskIcon` | `zendesk` | pair | #11110D | #FFFFFF | 18.31 | 12.47 | zendesk.com | +| `ZeroTierIcon` | `zerotier` | fixed | #FFB25B | #FFB25B | 16.58 | 6.99 | zerotier.com's own icon.svg and logo lockups | +| `ZitadelIcon` | `zitadel` | pair | #232323 | #FFFFFF | 15.21 | 12.47 | zitadel/zitadel console assets zitadel-logo-solo-dark.svg / zitadel-logo-solo-light.svg | +| `ZixflowIcon` | `zixflow` | pair | #141414 | #FFFFFF | 17.83 | 12.47 | docs.zixflow.com logo pack (logo/light.svg / logo/dark.svg) | +| `ZohoIcon` | `zoho` | pair | #000000 | #FFFFFF | 20.32 | 12.47 | zoho.com/branding (zoho-logo-web.svg / zoho-logo-white.svg) | +| `ZoomIcon` | `zoom` | pair | #0B5CFF | #FFFFFF | 5.09 | 12.47 | brand.zoom.com | +| `ZuploIcon` | `zuplo` | fixed | #FF00BD | #FF00BD | 3.39 | 3.56 | https://zuplo.com/brand | + +## Rules the brand imposes + +Constraints that would otherwise be broken by a well-meaning change. + +- **AblyIcon** — "Don't use other colours or gradients for the symbol." +- **AcceloIcon** — The mark keeps these three fills in both themes; only the wordmark (not drawn here) swaps #10202D for white. +- **AmqpIcon** — No brand colour: AMQP is an OASIS protocol, not a vendor, and amqp.org publishes no palette (https://www.amqp.org/legal.html). Generic glyph, deliberately monochrome — keep it on currentColor. +- **AnsibleIcon** — The mark is a solid disc knocked out with a white "A", so the pair is applied by inverting rather than currentColor: recolouring the disc alone would leave white on light grey. +- **ApifyIcon** — White/black variants are reserved for monochromatic contexts, so the tricolour mark stays in both themes. +- **AppwriteIcon** — Brand asks that the logo not be altered, so no per-theme variant. +- **ArcGisIcon** — Esri publishes no reversed variant for this badge and forbids altering its logos. +- **AsanaIcon** — Asana's guidelines forbid recolouring: the symbol always appears in coral, on light and dark backgrounds alike. +- **AssemblyAiIcon** — Two colours per theme, so the second stroke carries its own fill- utilities: #777673 on light, #FFFFFF on dark. +- **AttioIcon** — The mark is a filled compound path; stroking it instead thickens it and leaks the default black fill. +- **Auth0Icon** — Okta's content terms forbid altering the mark, so ship only these published variants. +- **AutheliaIcon** — authelia.com/reference/guides/branding permits format/layout changes only — do not alter the design. +- **AuthentikIcon** — The white variant is the brand's own asset for dark backgrounds; proportions and colour must not be altered otherwise. +- **AwsEcrIcon** — AWS ships one flat fill for both themes; the gradient tile was retired in the 2023 accessibility refresh. +- **AwsIcon** — aws.amazon.com/trademark-guidelines forbids altering the logo's colour, so only these two published variants may be used. +- **BaserowIcon** — The mark keeps these three colours on light and dark; only the wordmark reverses to white. +- **BeamerIcon** — The isotype is monochrome in all first-party artwork. +- **BigQueryIcon** — Google publishes no reversed variant, so the same mark is used on both themes. +- **BitbucketIcon** — Atlassian ships brand/neutral/inverse only: "don't use unapproved color combinations". +- **BitlyIcon** — Bitly's reversed logomark is white over orange, not over neutral dark, so the orange mark is used on both. +- **BloggerIcon** — Google publishes no reversed variant. +- **BlueskyIcon** — The downloadable media-kit butterfly still ships the older #006AFF, but the palette is the normative source: "use only the official color values above. Do not substitute, tint, or approximate." White is the approved monochrome variant for dark backgrounds. +- **BoxIcon** — box.com/legal/trademark forbids any other recolouring of the mark. +- **BrevoIcon** — Brevo publishes no per-theme variant of the app mark; the reversed "Mint" #F9FFF6 asset is the wordmark only. +- **BrowserlessIcon** — Its own prefers-color-scheme block sets black on light, white on dark. +- **BubbleIcon** — Bubble's brand terms forbid re-colouring the mark beyond its published dark/light pair. +- **BuildkiteIcon** — Buildkite ships a single mark "for any context", so there is no per-theme variant, and asks that it not be altered. +- **ButtondownIcon** — Brand forbids recolouring the logo, so no per-theme variant. +- **CSharpIcon** — Its README forbids altering the mark, so the same full-colour icon is used on light and dark. +- **CalcomIcon** — Cal.com's design system states it is deliberately a grayscale brand and publishes exactly two logo variants. +- **CalendlyIcon** — Guidelines: "Only show our logo and lockups in blue or white." +- **CertopusIcon** — Verbatim copy of the brand's own circle mark: #2C353D and the white disc are its other fixed tones, not a dark-theme variant. +- **CircleCiIcon** — Guidelines require Terminal (#161616) on light backgrounds and White on dark, and forbid any color not named in them. +- **CiscoIcon** — Cisco requires all parts of the mark be knocked out to white on dark backgrounds. +- **ClerkIcon** — Same two-tone symbol on light and dark; the mono symbol-dark/symbol-light pair is Clerk's alternate for single-colour contexts. +- **ClickhouseIcon** — Brand forbids recolouring the mark, so only its own published pair is used. +- **CloseIcon** — Close forbids modifying the logo, so the same colours are kept on light and dark. +- **CloudflareIcon** — Cloudflare's logo guidelines forbid altering the colours or filling the flare, so the flare stays knocked out on both themes. +- **CloudinaryIcon** — Cloudinary Blue is reserved for the logo; no other recolouring is permitted. +- **CockroachDbIcon** — The full-colour mark is a cyan-to-purple gradient; Cockroach Labs reduces it to solid white on dark backgrounds. +- **CoinbaseIcon** — Coinbase asks that the mark not be altered or recoloured, so only these two published variants are used. +- **ComapeoIcon** — Awana Digital publishes no reversed variant, so dark mode uses the brand's own accent blue #0066FF from CoMapeoLogo.svg (digidem/comapeo-mobile); the navy is 1.3:1 on dark surfaces. +- **ConfluenceIcon** — Atlassian requires the logo be used without modification, and its brand appearance is identical in light and dark. +- **ContentfulIcon** — Same full-colour mark on light and dark. +- **ContiguityIcon** — The `>_` glyph is knocked out to the opposite colour, so it carries its own fill- utilities. +- **ConvertKitIcon** — ConvertKit rebranded to Kit in 2024. +- **CssIcon** — Small-size variant; the only per-theme variants published are mono black/white fallbacks, so the rebeccapurple tile is kept in both themes. +- **DatadogIcon** — Datadog publishes one mark per background, the purple tile with Bits knocked out on light and the white Bits silhouette on dark, and forbids recolouring or inverting either. +- **DatoCmsIcon** — Brand kit forbids altering the logo's shape or colour. +- **DbtIcon** — Their Trademark Policy states "The dbt logo mark color cannot be altered", so this stays orange on both themes. +- **DeelIcon** — Post-rebrand the period is a square in the wordmark colour, not a blue circle. +- **DeepInfraIcon** — Their brand guidelines say "use the primary white logo on dark backgrounds", where the connector bars invert to #FFFFFF. +- **DenoIcon** — Deno publishes no hex and forbids colorizing; the black "Light (no outline)" and white "Dark (outlined)" marks are separate artworks to be swapped per background, never inverted. +- **DiscordIcon** — Discord forbids recolouring the logo, so no per-theme variant. +- **DiscourseIcon** — Only the outer bubble reverses; the five inner colours are the same in both variants. +- **DockerIcon** — Docker requires its logos appear only in its primary brand colours. +- **DropboxIcon** — Dropbox's branding terms forbid recolouring the logo, and their inverse-theme token keeps the same blue. +- **DuckDbIcon** — The pair is a full inversion, so the duck carries its own fill- utilities; the manual forbids recolouring outside these two brand hexes. +- **DustIcon** — Dust's guidelines forbid recolouring the logo. +- **DynatraceIcon** — Guidelines forbid colorizing the logo, so all six fills stay fixed in both themes. +- **EdgeDbIcon** — EdgeDB is now Gel — edgedb.com redirects to geldata.com — so this is Gel's "g" symbol, not the retired EDGE|DB wordmark. +- **EnodeIcon** — Their own favicon carries the pair in a prefers-color-scheme block. +- **ExaIcon** — Blue for standard applications, white on dark backgrounds. +- **FigmaIcon** — Figma's guidelines forbid modifying the marks, so the five-colour original is used on both themes. +- **FirebaseIcon** — Same full-colour artwork on light and dark; the guidelines forbid recolouring or redrawing the mark. +- **FoxentryIcon** — Fixed tri-tone mark, no per-theme variant: the brand ships a separate greyscale logo rather than a recoloured one. +- **FreshdeskIcon** — Freshworks publishes no reversed variant: the white glyph always sits on the green leaf. +- **FunkwhaleIcon** — Identity guidelines forbid recolouring. +- **GSheetsIcon** — Google forbids recolouring its marks, so the same full-colour artwork is used on both themes. +- **GcalIcon** — Google forbids modifying its logos, colour included, so this stays fixed with no per-theme pair. +- **GdriveIcon** — Google forbids modifying its logos "in any way, including changing the color", so this stays full-colour with no per-theme pair. +- **GiphyIcon** — Same full-colour mark on light and dark. +- **GitBookIcon** — #1C1917 is the marketing palette's dark base, not the logomark. +- **GitIcon** — A white reversed logomark exists, but git-scm.com's own dark theme exempts the mark from inversion and keeps it orange. +- **GithubIcon** — GitHub allows the Invertocat in white or black only and forbids recolouring it, so the pair is fixed here rather than inherited from the caller. +- **GmailIcon** — Google's brand guidelines forbid recolouring the mark. +- **GoogleAiIcon** — Google ships no reversed variant; the same gradient is used on light and dark. +- **GoogleCalendarIcon** — Google's trademark guidelines forbid distorting or altering a brand feature, so no per-theme recolour. +- **GoogleCloudIcon** — Google forbids recolouring its logos. +- **GoogleDriveIcon** — Google's Drive branding guide permits resizing only — no other change to the logo — so no per-theme recolour. +- **GoogleFormsIcon** — Google's brand guidelines forbid modifying or recolouring its product icons. +- **GoogleIcon** — developers.google.com/identity/branding-guidelines forbids changing the colour of the G. +- **GorgiasIcon** — The guide allows only black or white for the symbol: "Do not use gray!". +- **GristIcon** — "Keep it exactly as depicted — no recoloring, no cropping." +- **GroqIcon** — Logo use in a UI requires a license from Groq. +- **HoldedIcon** — Holded ships one flat red mark for both themes; the red-orange gradient is retired. +- **HubspotIcon** — The legacy Coral #FF7A59 is not the current logo color. +- **IfsIcon** — IFS's negative lockup reverses only the wordmark, so the symbol keeps its #8427E2-to-#72C9F8 gradient on dark. +- **IftttIcon** — IFTTT's brand guidelines state "Our wordmark may be used in solid white or black" and publish no other hex for the mark. +- **IntercomIcon** — Intercom ships the mark as fill="currentColor" bound to its nav foreground token, so it takes the colour of the surface it sits on. +- **JavaIcon** — The Coffee Cup mark is licensee-only and "you may not use a modified version of the Coffee Cup logo" — do not recolour it or flatten it to currentColor. +- **JavaScriptIcon** — Fixed mark: yellow field, black lettering, no per-theme variant. +- **JiraIcon** — The logomark is identical on light and dark; only the wordmark changes colour. +- **JoomlaIcon** — Joomla's trademark policy forbids recolouring the mark, so there is no per-theme variant. +- **JotformIcon** — The other three bars keep their fixed brand colours in both themes. +- **JsonIcon** — JSON itself has no brand owner or published colours — json.org states none — so this is a Material palette pick, not a brand colour. +- **KanidmIcon** — Kanidm's artwork is CC-BY-NC-ND — no recolouring or other derivatives. +- **KlaviyoIcon** — Klaviyo draws it in currentColor, hence the white swap on dark. +- **LangfuseIcon** — Langfuse's trademark terms forbid modifying the assets. +- **LineIcon** — LINE forbids any change to the logo's colour, so there is no reversed variant. +- **LinearIcon** — Guidelines ship a light/dark logomark pair and forbid altering the assets in any other way. +- **LinkedinIcon** — That page forbids recolouring: only the approved blue, black and white variants. +- **LinodeIcon** — The keyline path stays unfilled so it follows currentColor instead of the source's near-black #231f20. +- **LumaAiIcon** — The two faces ship at 65% opacity, which is what makes their overlap read as a cube. +- **MSSqlServerIcon** — Microsoft licenses its product icons for diagrams, docs, and training only, and forbids cropping, rotating, or reshaping them. +- **MSTeamsIcon** — Microsoft's trademark guidelines forbid altering their brand assets, so the full-colour mark ships unchanged in both themes. +- **MailchimpIcon** — Mailchimp forbids altering the files, so both official tones are painted and neither is recoloured per theme. +- **MailerLiteIcon** — Their IP guidelines forbid altering or recolouring the mark. +- **MailgunIcon** — Mailgun ships no reversed variant; the tile is identical on light and dark. +- **MandrillIcon** — On dark their rule is the reversed (white) Freddie; Cavendish Yellow #FFE01B is a background colour, never the mark. +- **MarkdownIcon** — Spec: keep the enclosure's aspect ratio and radius, keep the M/arrow/box relative sizes, and draw all three in one colour. +- **MastodonIcon** — Swap to the black or white logo rather than recolouring when contrast fails. +- **MatrixIcon** — Artwork is the Foundation's matrix-icon.svg verbatim; the trademark policy forbids altering it. +- **MediumIcon** — Guidelines mandate black or white only for both the wordmark and the icon and forbid "any other colors, gradients, or filled with images". +- **MezmoIcon** — The star stays #F4B811 in both themes. +- **MicrosoftIcon** — Microsoft forbids recolouring the symbol, so it stays full-colour on both themes. +- **MistralIcon** — Brand forbids any other recolouring. +- **MixpanelIcon** — "The Mixpanel logo is only ever used in three colors: black, white and the primary brand purple." +- **MollieIcon** — The m is the glyph from Mollie-Logo-Black-2023.svg (Mollie logo pack, mollie.com/resources), scaled and placed to match that icon pixel for pixel; the logo pack itself ships only the 320x94 wordmark. Mollie publishes black and white variants, so the pair flips for dark mode. +- **MondayIcon** — All three colours are required; the brand forbids monochrome or recoloured versions, so no dark-theme variant. +- **MongodbIcon** — MongoDB permits only four logo colours, chosen for contrast with the background, and forbids any other recolour. +- **MotimateIcon** — Motimate is a registered trademark of Motimate AS (Kahoot!). +- **Mysql** — Used under Fair Use: https://fr.wikipedia.org/wiki/Fichier:MySQL.svg +- **NeonDbIcon** — Neon forbids recolouring, so only these published variants may be used. +- **NetlifyIcon** — Two colours per theme, so the "n" carries its own fill- utilities: #014847 on light, #FFFFFF on dark. +- **NetsuiteIcon** — Pre-Oracle NetSuite "N" mark. #125580/#baccdb approximate netsuite.com's own 2014 logo art, which is itself inconsistent: /portal/common/img/ns-logo.png is #14487e/#b9c9d5 and /portal/common/img/logo-ns-mobile.png is #13527d/#b6c7d5 (both via web.archive.org/web/2014/). Not Oracle's current NetSuite mark, which is a different logo in a different palette (#264759/#36677D/#94BFCE/#E2C06B). +- **NocoDbIcon** — NocoDB publishes no reversed variant; the full-colour mark is used on light and dark alike. +- **NotionIcon** — The plate is fixed, not theme-swapped: the mark is pure black and disappears on dark backgrounds without it. +- **NuIcon** — Nushell registers that one file as both the `light` and `dark` icon, so the green is not theme-swapped. +- **OdkIcon** — ODK publishes no reversed or monochrome variant. +- **OktaIcon** — Okta's official April-2025 logo package (logos-04-2025.zip) ships the mark in Black and White only. +- **OpenWeatherIcon** — Their negative (dark-background) logo reverses only the wordmark; the symbol stays brand orange. +- **OpenaiIcon** — The guidelines state "DON'T add any colors to the Blossom" — black or white only. +- **OracleDBIcon** — Oracle reserves its logo for licensees. +- **PHPIcon** — Official logo, CC BY-SA 4.0: keep it verbatim and credit Colin Viebrock rather than recolouring. +- **PaychexIcon** — The isolated P is the square mark Paychex ships as its own 192x192 app icon. Paychex requires prior approval for any use of its marks. +- **PaypalIcon** — The third fill is the deep/bright blue overlap: a two-colour or flat fill loses it. +- **PersonioIcon** — Black on light, white on dark or coloured backgrounds. +- **PhraseIcon** — The green wedge stays #03EAB3 in both — Phrase forbids altering the logo mark colour. +- **PineconeIcon** — The mark is stroke-only, so fill must stay none. +- **PinterestIcon** — Brand guidelines: "Do not alter the logo colour." +- **PipedriveIcon** — Pipedrive's partner media kit says "do not alter, rotate, modify or animate the logo", so the mark keeps its published colours on both themes. +- **PostgresIcon** — The PostgreSQL trademark policy forbids recolouring the mark without prior approval. +- **PostmarkIcon** — Postmark publishes no reversed variant. +- **PowershellIcon** — Trademarked Microsoft logo, exempt from that repo's MIT license. The #00FF18 line below is opacity-0 in the upstream asset and paints nothing. +- **PusherIcon** — Only the colourways shown in their brand guidelines are permitted. +- **PushoverIcon** — Forbids recolouring. +- **QoveryIcon** — The mark keeps the same purple in Qovery's white lockup for dark backgrounds, so there is no reversed variant. +- **QuickbooksIcon** — Intuit forbids altering the mark. +- **RIcon** — R Foundation licenses the mark CC-BY-SA 4.0 / GPL-2 — attribution required, changes must be indicated. +- **RaindropIcon** — Full-colour mark, no reversed variant published. +- **ReadwiseIcon** — The serif R and its highlight block are knocked out to the opposite colour, so they carry their own fill- utilities. Do not restore the mix-blend-mode: multiply wrapper Readwise's dark file carries: it turns the knocked-out white to the backdrop colour on anything but a white page. +- **RecraftIcon** — The plated mark carries its own background: recraft.ai serves it to prefers-color-scheme light and dark alike — not a theme pair. +- **RedditIcon** — Reddit publishes no reversed variant; the icon must always appear in Orangered when in colour. +- **RenderIcon** — Official Render Brand Kit contains only Black and White logomark folders and the SVGs use pure black / pure white. +- **ResendIcon** — Brand guidelines forbid multi-color use or altering the mark, so these are the only two published fills. +- **RssIcon** — Never rotate or flip the mark. +- **RubyIcon** — CC BY-SA 2.5; the kit's LICENSE asks that the mark not represent anything other than the Ruby language. +- **RunPodIcon** — Brand forbids recolouring, so both values are its own published cube-icon variants. +- **RustIcon** — rust-lang.org ships only rust-logo-blk.svg (pure black). +- **S3Icon** — AWS ships no dark variant for service icons. +- **SageIcon** — Sage sets its logo black on light surfaces and Sage green only on dark ones. +- **SalesforceIcon** — Salesforce reserves the white/reversed cloud for its own blue backgrounds, so the blue mark stands in both themes. +- **SegmentIcon** — Segment ships the mark in one flat green and publishes no reversed variant. +- **SendflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SendgridIcon** — Twilio's trademark guidelines forbid recolouring the mark, so it stays multicolour in both themes. +- **ServiceNowIcon** — Trademark guidelines require the mark in the graphic form provided. +- **ShopifyIcon** — The "S" stays white regardless of background; no gradients, shadows or recolouring. +- **ShortcutIcon** — The mark "is used across various colors but never changes its visual structure." +- **ShutterstockIcon** — Brand rule: the logo is only ever black or white. +- **SigNozIcon** — SigNoz ships no reversed variant; the tile mark is used unchanged on light and dark. +- **Slack** — Fixed full-colour mark, no per-theme variant. +- **SmartsheetIcon** — Those are two of the approved logo colorways; the guide forbids any other recolouring. +- **SnowflakeIcon** — Snowflake Blue is the only approved logo color; the sole alternate is a white reverse reserved for full-bleed Snowflake Blue. +- **SpeechifyIcon** — The blue logomark is reserved for white backgrounds; every other background takes the black or white monochrome version. +- **SplitwiseIcon** — Splitwise's logos carry a single green and no reversed variant, so the same colour is used on light and dark. +- **SpotifyIcon** — Spotify permits the green icon only on black or white backgrounds and requires the white monochrome colourway on any other dark background, so the pair is fixed rather than caller-set. +- **SquareIcon** — Square ships only black and white logo files and states "Do not change the color", so no tinted variant is allowed. +- **StraleIcon** — Strale's own logo component fills with currentColor, so the mark is meant to take the theme's foreground. +- **StravaIcon** — Strava's guidelines forbid modifying or altering its logos, and the orange Echelon is the mark Strava itself uses on light and dark alike. +- **StripeIcon** — Stripe's Marks Usage Terms forbid altering the marks, so this ships verbatim in both themes rather than as a recoloured pair. +- **SupabaseIcon** — Forbids modifying or recolouring the mark. +- **SurrealdbIcon** — Same gradient mark on light and dark; monochrome variants are for subtle placements only. +- **SvelteIcon** — Its guidelines count the official colour scheme as part of the mark — do not recolour. +- **TaskadeIcon** — Brand forbids recolouring, so only those two published variants are used. +- **TelegramIcon** — The shaded-plane drawing is Telegram's retired Logo_old. +- **TerraIcon** — The outlines are part of the artwork and stay black in both themes; the blue T carries the mark on dark backgrounds. +- **TheirStackIcon** — The mark ships black-only, but the same brand rules forbid placing it on low-contrast backgrounds; on a dark surface black is 1.68:1, so it is tinted to the brand's own white. +- **ThreadsIcon** — The pack ships the mark in black and white only, so it must never be tinted. +- **TogglIcon** — Toggl requires the mark be used as is, unmodified. +- **TomorrowIoIcon** — Their stylesheet reverses only the wordmark on dark headers (path.logo-letter{fill:#fff}); the mark itself stays logo blue in both themes. +- **TrelloIcon** — Atlassian: never compose your own versions or deconstruct official assets. +- **TripadvisorIcon** — Dark backgrounds take Tripadvisor's separate outlined Ollie, never an inverted or recoloured one. +- **TwilioIcon** — Twilio reserves its corporate logo for permitted use and forbids recreating or modifying it. +- **TwitchIcon** — Trademark guidelines forbid recolouring. +- **TwitterIcon** — The component draws the X glyph, not the legacy bird. +- **TypeformIcon** — Default brand colours are "Paper (white) and Ink (black)". +- **UltravoxIcon** — Gradient mark; ultravox.ai links that same asset for both prefers-color-scheme light and dark, so there is no per-theme variant. +- **VectaraIcon** — #7E00FF → #07FEEE iridescent sweep, sampled from the logo mark on vectara.com (no brand kit is published). Vectara reserves its trademarks: do not recolour. +- **VercelIcon** — Vercel ships only light-theme (black) and dark-theme (white) triangle marks and explicitly forbids modifying or recoloring the trademarks. +- **VismaIcon** — Positive black on light, negative white on dark; the logo may not be given any other colour. +- **VueIcon** — Vue's dark-background variant is separate outlined artwork rather than a recolour, so the two-tone mark is used in both themes. +- **WebflowIcon** — Mark ships in blue, black or white only. +- **WhatsappBusinessIcon** — "You shouldn't modify any colors in our logos." +- **WooCommerceIcon** — Automattic requires the mark in its exact, most up-to-date form, so only their two published colorways are used, never a recolour. +- **WordpressIcon** — Every official logotype vector is BaseGray #32373C, shipped alongside a White/transparent version for dark backgrounds. +- **XataIcon** — Brand forbids recolouring, and the full-colour symbol is the same purple in light and dark modes. +- **XeroIcon** — Single-colour mark: white wordmark on the blue badge in both themes. +- **YamlIcon** — YAML publishes no reversed variant, and its black letters would be invisible on the dark surface. +- **YelpIcon** — Yelp forbids altering the logos and requires the ® to accompany the mark at all times. +- **YoutubeIcon** — "The triangle in the full-color red icon must always be white." +- **ZendeskIcon** — Zendesk's brand guidelines specify the logo in Licorice #11110D and Coconut #FFFFFF only and forbid unapproved color variations. +- **ZeroTierIcon** — The tile is identical on light and dark; only the wordmark inverts. +- **ZitadelIcon** — The gradient chevrons stay #FF8F00→#FE00FF in both variants. +- **ZohoIcon** — The four squares carry their own brand hexes (#E42527/#089949/#226DB4/#F9B21D) on light; Zoho's reversed lock-up is entirely white, so they invert with the wordmark on dark. +- **ZoomIcon** — The logo "may only be used in Bloom (#0B5CFF), White, or Black", with White reserved for dark backgrounds and Black requiring prior brand approval. +- **ZuploIcon** — Brand guidelines forbid recolouring the mark; pink is the official variant on both light and dark surfaces. + +## Concept icons + +Not brands. These inherit `currentColor` on purpose and must not be given a pair. + +`AgentInstructionsIcon`, `AiAgentIcon`, `ApiKeyAuthIcon`, `AssetDatabaseIcon`, `AssetDucklakeIcon`, `AssetGenericIcon`, `AssetResIcon`, `AssetS3Icon`, `BarsStaggered`, `BasicHttpAuthIcon`, `BcryptIcon`, `CACertificate`, `CustomAiIcon`, `DbIcon`, `FormInputIcon`, `FunnelCog`, `GpgKeyIcon`, `HttpIcon`, `JsonSchemaIcon`, `LdapIcon`, `Mail`, `OauthIcon`, `PaintbrushOff`, `QRCodeIcon`, `QuestionInputIcon`, `RecordIcon`, `RestIcon`, `SchedulePollIcon`, `SignatureAuthIcon`, `SparklesOffIcon`, `WebdavIcon`, `WindmillAiIcon`, `WindmillIcon`, `WindmillIcon2` + +## Coverage + +- brand icons: **314**, of which **310** carry a recorded source +- per-theme pairs applied: **136** (5 of them by inversion or a two-SVG swap, see above) +- concept icons: **34** +- effectively invisible on light: **1** (AbstractApiIcon) +- effectively invisible on dark: **2** (PaychexIcon, TripadvisorIcon) diff --git a/frontend/src/lib/components/icons/BambooHrIcon.svelte b/frontend/src/lib/components/icons/BambooHrIcon.svelte index efbeec2ece..534b48cbe6 100644 --- a/frontend/src/lib/components/icons/BambooHrIcon.svelte +++ b/frontend/src/lib/components/icons/BambooHrIcon.svelte @@ -1,12 +1,27 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BaremetricsIcon.svelte b/frontend/src/lib/components/icons/BaremetricsIcon.svelte index be550a88fc..a05a84c7c9 100644 --- a/frontend/src/lib/components/icons/BaremetricsIcon.svelte +++ b/frontend/src/lib/components/icons/BaremetricsIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BaserowIcon.svelte b/frontend/src/lib/components/icons/BaserowIcon.svelte new file mode 100644 index 0000000000..5d51c8cfa5 --- /dev/null +++ b/frontend/src/lib/components/icons/BaserowIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte new file mode 100644 index 0000000000..2a570cfb3e --- /dev/null +++ b/frontend/src/lib/components/icons/BasicHttpAuthIcon.svelte @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/BasisTheoryIcon.svelte b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte new file mode 100644 index 0000000000..6f2dfc2d4e --- /dev/null +++ b/frontend/src/lib/components/icons/BasisTheoryIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/BcryptIcon.svelte b/frontend/src/lib/components/icons/BcryptIcon.svelte index bf9bf9b6a2..be79ba1418 100644 --- a/frontend/src/lib/components/icons/BcryptIcon.svelte +++ b/frontend/src/lib/components/icons/BcryptIcon.svelte @@ -1,15 +1,39 @@ - - - - + + + + diff --git a/frontend/src/lib/components/icons/BeamerIcon.svelte b/frontend/src/lib/components/icons/BeamerIcon.svelte new file mode 100644 index 0000000000..696eac11f4 --- /dev/null +++ b/frontend/src/lib/components/icons/BeamerIcon.svelte @@ -0,0 +1,35 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/BigQueryIcon.svelte b/frontend/src/lib/components/icons/BigQueryIcon.svelte index 10d099f75d..43958d302e 100644 --- a/frontend/src/lib/components/icons/BigQueryIcon.svelte +++ b/frontend/src/lib/components/icons/BigQueryIcon.svelte @@ -1,42 +1,36 @@ + -Icon_24px_BigQuery_Color + + + + + + + + diff --git a/frontend/src/lib/components/icons/BitbucketIcon.svelte b/frontend/src/lib/components/icons/BitbucketIcon.svelte index 9b33e6bb10..ca368edc70 100644 --- a/frontend/src/lib/components/icons/BitbucketIcon.svelte +++ b/frontend/src/lib/components/icons/BitbucketIcon.svelte @@ -1,24 +1,23 @@ - - - - - - - - Bitbucket-blue - - - - - - - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/BitlyIcon.svelte b/frontend/src/lib/components/icons/BitlyIcon.svelte index 3cc3b43122..a023981cec 100644 --- a/frontend/src/lib/components/icons/BitlyIcon.svelte +++ b/frontend/src/lib/components/icons/BitlyIcon.svelte @@ -1,12 +1,21 @@ + - - + + diff --git a/frontend/src/lib/components/icons/BloggerIcon.svelte b/frontend/src/lib/components/icons/BloggerIcon.svelte index ab1d2d262f..13ec49a1c0 100644 --- a/frontend/src/lib/components/icons/BloggerIcon.svelte +++ b/frontend/src/lib/components/icons/BloggerIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/BlueskyIcon.svelte b/frontend/src/lib/components/icons/BlueskyIcon.svelte index 1f8545e325..68dea0f3d2 100644 --- a/frontend/src/lib/components/icons/BlueskyIcon.svelte +++ b/frontend/src/lib/components/icons/BlueskyIcon.svelte @@ -1,12 +1,25 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BotifyIcon.svelte b/frontend/src/lib/components/icons/BotifyIcon.svelte new file mode 100644 index 0000000000..375e128ac4 --- /dev/null +++ b/frontend/src/lib/components/icons/BotifyIcon.svelte @@ -0,0 +1,16 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/BoxIcon.svelte b/frontend/src/lib/components/icons/BoxIcon.svelte index a6a5d5dc07..980d8f5757 100644 --- a/frontend/src/lib/components/icons/BoxIcon.svelte +++ b/frontend/src/lib/components/icons/BoxIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrandLetterIcon.svelte b/frontend/src/lib/components/icons/BrandLetterIcon.svelte new file mode 100644 index 0000000000..a9e9fa2966 --- /dev/null +++ b/frontend/src/lib/components/icons/BrandLetterIcon.svelte @@ -0,0 +1,58 @@ + + + + + + {letter} + diff --git a/frontend/src/lib/components/icons/BrevoIcon.svelte b/frontend/src/lib/components/icons/BrevoIcon.svelte index 2907c346f7..971fb5d9f3 100644 --- a/frontend/src/lib/components/icons/BrevoIcon.svelte +++ b/frontend/src/lib/components/icons/BrevoIcon.svelte @@ -1,12 +1,20 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/BrexIcon.svelte b/frontend/src/lib/components/icons/BrexIcon.svelte index 5f9fbf8de1..526b2fe536 100644 --- a/frontend/src/lib/components/icons/BrexIcon.svelte +++ b/frontend/src/lib/components/icons/BrexIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BrowserlessIcon.svelte b/frontend/src/lib/components/icons/BrowserlessIcon.svelte index 768d51145c..9dcf785adf 100644 --- a/frontend/src/lib/components/icons/BrowserlessIcon.svelte +++ b/frontend/src/lib/components/icons/BrowserlessIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/BubbleIcon.svelte b/frontend/src/lib/components/icons/BubbleIcon.svelte index fdfb049e70..15277d5436 100644 --- a/frontend/src/lib/components/icons/BubbleIcon.svelte +++ b/frontend/src/lib/components/icons/BubbleIcon.svelte @@ -1,16 +1,24 @@ + - - - - - + + + + diff --git a/frontend/src/lib/components/icons/BuildkiteIcon.svelte b/frontend/src/lib/components/icons/BuildkiteIcon.svelte index 282d782e31..e510c61449 100644 --- a/frontend/src/lib/components/icons/BuildkiteIcon.svelte +++ b/frontend/src/lib/components/icons/BuildkiteIcon.svelte @@ -1,12 +1,16 @@ - - + + + + + + diff --git a/frontend/src/lib/components/icons/BunIcon.svelte b/frontend/src/lib/components/icons/BunIcon.svelte index ef845d4834..792ad10b3c 100644 --- a/frontend/src/lib/components/icons/BunIcon.svelte +++ b/frontend/src/lib/components/icons/BunIcon.svelte @@ -1,12 +1,13 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + diff --git a/frontend/src/lib/components/icons/CACertificate.svelte b/frontend/src/lib/components/icons/CACertificate.svelte index c73aa5d3ca..d6a6a6cdde 100644 --- a/frontend/src/lib/components/icons/CACertificate.svelte +++ b/frontend/src/lib/components/icons/CACertificate.svelte @@ -1,12 +1,17 @@ - - \ No newline at end of file + + diff --git a/frontend/src/lib/components/icons/CSharpIcon.svelte b/frontend/src/lib/components/icons/CSharpIcon.svelte index 703c692036..c9c06d77cb 100644 --- a/frontend/src/lib/components/icons/CSharpIcon.svelte +++ b/frontend/src/lib/components/icons/CSharpIcon.svelte @@ -1,18 +1,41 @@ - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CalcomIcon.svelte b/frontend/src/lib/components/icons/CalcomIcon.svelte index 382b8e7a56..5dea23818d 100644 --- a/frontend/src/lib/components/icons/CalcomIcon.svelte +++ b/frontend/src/lib/components/icons/CalcomIcon.svelte @@ -1,13 +1,16 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() - - + + + diff --git a/frontend/src/lib/components/icons/CampaynIcon.svelte b/frontend/src/lib/components/icons/CampaynIcon.svelte new file mode 100644 index 0000000000..c343cb186f --- /dev/null +++ b/frontend/src/lib/components/icons/CampaynIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CertopusIcon.svelte b/frontend/src/lib/components/icons/CertopusIcon.svelte new file mode 100644 index 0000000000..227528bae2 --- /dev/null +++ b/frontend/src/lib/components/icons/CertopusIcon.svelte @@ -0,0 +1,38 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/ChromaIcon.svelte b/frontend/src/lib/components/icons/ChromaIcon.svelte new file mode 100644 index 0000000000..b42a6b58e4 --- /dev/null +++ b/frontend/src/lib/components/icons/ChromaIcon.svelte @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CircleCiIcon.svelte b/frontend/src/lib/components/icons/CircleCiIcon.svelte index 92402ac6cd..59c97c4e37 100644 --- a/frontend/src/lib/components/icons/CircleCiIcon.svelte +++ b/frontend/src/lib/components/icons/CircleCiIcon.svelte @@ -1,12 +1,23 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CiscoIcon.svelte b/frontend/src/lib/components/icons/CiscoIcon.svelte index d2d4b14bd8..aa3ed22d0f 100644 --- a/frontend/src/lib/components/icons/CiscoIcon.svelte +++ b/frontend/src/lib/components/icons/CiscoIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/ClaudeIcon.svelte b/frontend/src/lib/components/icons/ClaudeIcon.svelte index 5c5cdeffe5..44ec5723c2 100644 --- a/frontend/src/lib/components/icons/ClaudeIcon.svelte +++ b/frontend/src/lib/components/icons/ClaudeIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + - + diff --git a/frontend/src/lib/components/icons/ClerkIcon.svelte b/frontend/src/lib/components/icons/ClerkIcon.svelte index 21baeae38d..9649794578 100644 --- a/frontend/src/lib/components/icons/ClerkIcon.svelte +++ b/frontend/src/lib/components/icons/ClerkIcon.svelte @@ -1,12 +1,24 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/ClickhouseIcon.svelte b/frontend/src/lib/components/icons/ClickhouseIcon.svelte index 6d3870762a..a1ea211f36 100644 --- a/frontend/src/lib/components/icons/ClickhouseIcon.svelte +++ b/frontend/src/lib/components/icons/ClickhouseIcon.svelte @@ -1,27 +1,36 @@ + - \ No newline at end of file + + + + diff --git a/frontend/src/lib/components/icons/ClickupIcon.svelte b/frontend/src/lib/components/icons/ClickupIcon.svelte index 22e160931a..e84ddc0633 100644 --- a/frontend/src/lib/components/icons/ClickupIcon.svelte +++ b/frontend/src/lib/components/icons/ClickupIcon.svelte @@ -1,10 +1,11 @@ + - - + + - + diff --git a/frontend/src/lib/components/icons/CloseIcon.svelte b/frontend/src/lib/components/icons/CloseIcon.svelte index 73e6f414f9..e61b7ada6c 100644 --- a/frontend/src/lib/components/icons/CloseIcon.svelte +++ b/frontend/src/lib/components/icons/CloseIcon.svelte @@ -1,42 +1,37 @@ + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/frontend/src/lib/components/icons/CloudflareIcon.svelte b/frontend/src/lib/components/icons/CloudflareIcon.svelte index 6ade506d8a..7fed848d2d 100644 --- a/frontend/src/lib/components/icons/CloudflareIcon.svelte +++ b/frontend/src/lib/components/icons/CloudflareIcon.svelte @@ -1,12 +1,13 @@ + - diff --git a/frontend/src/lib/components/icons/CloudinaryIcon.svelte b/frontend/src/lib/components/icons/CloudinaryIcon.svelte index a61f3bbe7c..9dc633dccd 100644 --- a/frontend/src/lib/components/icons/CloudinaryIcon.svelte +++ b/frontend/src/lib/components/icons/CloudinaryIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CockroachDbIcon.svelte b/frontend/src/lib/components/icons/CockroachDbIcon.svelte index d88f7c52de..2953c152f5 100644 --- a/frontend/src/lib/components/icons/CockroachDbIcon.svelte +++ b/frontend/src/lib/components/icons/CockroachDbIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CodaIcon.svelte b/frontend/src/lib/components/icons/CodaIcon.svelte index d8486224d9..9a58fb8998 100644 --- a/frontend/src/lib/components/icons/CodaIcon.svelte +++ b/frontend/src/lib/components/icons/CodaIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/CodatIcon.svelte b/frontend/src/lib/components/icons/CodatIcon.svelte new file mode 100644 index 0000000000..732a4a878e --- /dev/null +++ b/frontend/src/lib/components/icons/CodatIcon.svelte @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CohereIcon.svelte b/frontend/src/lib/components/icons/CohereIcon.svelte index c0a4aea8ee..dba01ef6ed 100644 --- a/frontend/src/lib/components/icons/CohereIcon.svelte +++ b/frontend/src/lib/components/icons/CohereIcon.svelte @@ -1,21 +1,35 @@ + - - - - - - - - - - + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte index 770b60f40a..67ff2998c8 100644 --- a/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte +++ b/frontend/src/lib/components/icons/CoinMarketCapIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/CoinbaseIcon.svelte b/frontend/src/lib/components/icons/CoinbaseIcon.svelte index b58a45fff0..5d61e83df7 100644 --- a/frontend/src/lib/components/icons/CoinbaseIcon.svelte +++ b/frontend/src/lib/components/icons/CoinbaseIcon.svelte @@ -1,12 +1,23 @@ + - - + + diff --git a/frontend/src/lib/components/icons/ComapeoIcon.svelte b/frontend/src/lib/components/icons/ComapeoIcon.svelte new file mode 100644 index 0000000000..062c45e087 --- /dev/null +++ b/frontend/src/lib/components/icons/ComapeoIcon.svelte @@ -0,0 +1,25 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/ConfluenceIcon.svelte b/frontend/src/lib/components/icons/ConfluenceIcon.svelte index 60dbcef880..a892e912f2 100644 --- a/frontend/src/lib/components/icons/ConfluenceIcon.svelte +++ b/frontend/src/lib/components/icons/ConfluenceIcon.svelte @@ -1,12 +1,17 @@ + - - + + + diff --git a/frontend/src/lib/components/icons/ContentfulIcon.svelte b/frontend/src/lib/components/icons/ContentfulIcon.svelte index 2af55d48fc..38f466d92d 100644 --- a/frontend/src/lib/components/icons/ContentfulIcon.svelte +++ b/frontend/src/lib/components/icons/ContentfulIcon.svelte @@ -1,12 +1,32 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/ContiguityIcon.svelte b/frontend/src/lib/components/icons/ContiguityIcon.svelte new file mode 100644 index 0000000000..30de7f9c13 --- /dev/null +++ b/frontend/src/lib/components/icons/ContiguityIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/lib/components/icons/ConvertKitIcon.svelte b/frontend/src/lib/components/icons/ConvertKitIcon.svelte index 2fbcf0e53f..0506f62f43 100644 --- a/frontend/src/lib/components/icons/ConvertKitIcon.svelte +++ b/frontend/src/lib/components/icons/ConvertKitIcon.svelte @@ -1,12 +1,24 @@ - - + + + + + diff --git a/frontend/src/lib/components/icons/CoupaIcon.svelte b/frontend/src/lib/components/icons/CoupaIcon.svelte index 4d4058fdac..a8c780bdbc 100644 --- a/frontend/src/lib/components/icons/CoupaIcon.svelte +++ b/frontend/src/lib/components/icons/CoupaIcon.svelte @@ -1,19 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/CustomAiIcon.svelte b/frontend/src/lib/components/icons/CustomAiIcon.svelte new file mode 100644 index 0000000000..a646753b7c --- /dev/null +++ b/frontend/src/lib/components/icons/CustomAiIcon.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/DatabricksIcon.svelte b/frontend/src/lib/components/icons/DatabricksIcon.svelte index e767337442..e06471cdd4 100644 --- a/frontend/src/lib/components/icons/DatabricksIcon.svelte +++ b/frontend/src/lib/components/icons/DatabricksIcon.svelte @@ -1,12 +1,13 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/DatoCmsIcon.svelte b/frontend/src/lib/components/icons/DatoCmsIcon.svelte index d462832e17..e2e4b2be0e 100644 --- a/frontend/src/lib/components/icons/DatoCmsIcon.svelte +++ b/frontend/src/lib/components/icons/DatoCmsIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DbIcon.svelte b/frontend/src/lib/components/icons/DbIcon.svelte index fe74ca9adb..44227ad43a 100644 --- a/frontend/src/lib/components/icons/DbIcon.svelte +++ b/frontend/src/lib/components/icons/DbIcon.svelte @@ -1,17 +1,17 @@ diff --git a/frontend/src/lib/components/icons/DbtIcon.svelte b/frontend/src/lib/components/icons/DbtIcon.svelte index b647cb215e..a90f82b90f 100644 --- a/frontend/src/lib/components/icons/DbtIcon.svelte +++ b/frontend/src/lib/components/icons/DbtIcon.svelte @@ -7,21 +7,18 @@ let { height = 24, width = 24 }: Props = $props() + - - - - - - - - + diff --git a/frontend/src/lib/components/icons/DeelIcon.svelte b/frontend/src/lib/components/icons/DeelIcon.svelte index 0b93a4a98f..e36b64fe3f 100644 --- a/frontend/src/lib/components/icons/DeelIcon.svelte +++ b/frontend/src/lib/components/icons/DeelIcon.svelte @@ -1,12 +1,25 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/DeepInfraIcon.svelte b/frontend/src/lib/components/icons/DeepInfraIcon.svelte new file mode 100644 index 0000000000..3750b5ff42 --- /dev/null +++ b/frontend/src/lib/components/icons/DeepInfraIcon.svelte @@ -0,0 +1,27 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/DeepLIcon.svelte b/frontend/src/lib/components/icons/DeepLIcon.svelte index 028b061dc0..58b2a14833 100644 --- a/frontend/src/lib/components/icons/DeepLIcon.svelte +++ b/frontend/src/lib/components/icons/DeepLIcon.svelte @@ -1,12 +1,40 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DeepSeekIcon.svelte b/frontend/src/lib/components/icons/DeepSeekIcon.svelte index d0ba0c6423..560c5704bb 100644 --- a/frontend/src/lib/components/icons/DeepSeekIcon.svelte +++ b/frontend/src/lib/components/icons/DeepSeekIcon.svelte @@ -7,7 +7,15 @@ let { height = '24px', width = '24px' }: Props = $props() - + + diff --git a/frontend/src/lib/components/icons/DenoIcon.svelte b/frontend/src/lib/components/icons/DenoIcon.svelte index 86dca42410..776144b34f 100644 --- a/frontend/src/lib/components/icons/DenoIcon.svelte +++ b/frontend/src/lib/components/icons/DenoIcon.svelte @@ -1,41 +1,44 @@ + + + + + diff --git a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte index 206e395175..6daeb458d0 100644 --- a/frontend/src/lib/components/icons/DigitalOceanIcon.svelte +++ b/frontend/src/lib/components/icons/DigitalOceanIcon.svelte @@ -1,12 +1,15 @@ + - + diff --git a/frontend/src/lib/components/icons/DiscordIcon.svelte b/frontend/src/lib/components/icons/DiscordIcon.svelte index 384008b773..ca92f30ed4 100644 --- a/frontend/src/lib/components/icons/DiscordIcon.svelte +++ b/frontend/src/lib/components/icons/DiscordIcon.svelte @@ -1,21 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/DiscourseIcon.svelte b/frontend/src/lib/components/icons/DiscourseIcon.svelte index fd1e53f9a3..dd590cee4a 100644 --- a/frontend/src/lib/components/icons/DiscourseIcon.svelte +++ b/frontend/src/lib/components/icons/DiscourseIcon.svelte @@ -1,12 +1,38 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DocSpringIcon.svelte b/frontend/src/lib/components/icons/DocSpringIcon.svelte new file mode 100644 index 0000000000..f67ba12c70 --- /dev/null +++ b/frontend/src/lib/components/icons/DocSpringIcon.svelte @@ -0,0 +1,55 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DockerIcon.svelte b/frontend/src/lib/components/icons/DockerIcon.svelte index d7ab3f498c..f5a5ac11a5 100644 --- a/frontend/src/lib/components/icons/DockerIcon.svelte +++ b/frontend/src/lib/components/icons/DockerIcon.svelte @@ -1,27 +1,22 @@ + diff --git a/frontend/src/lib/components/icons/DocusignIcon.svelte b/frontend/src/lib/components/icons/DocusignIcon.svelte index 91d96bac36..5fe8c42f82 100644 --- a/frontend/src/lib/components/icons/DocusignIcon.svelte +++ b/frontend/src/lib/components/icons/DocusignIcon.svelte @@ -1,12 +1,30 @@ + - - + + + + diff --git a/frontend/src/lib/components/icons/DropboxIcon.svelte b/frontend/src/lib/components/icons/DropboxIcon.svelte index 3facd1dda2..51821fdd3e 100644 --- a/frontend/src/lib/components/icons/DropboxIcon.svelte +++ b/frontend/src/lib/components/icons/DropboxIcon.svelte @@ -1,12 +1,15 @@ - - + + + diff --git a/frontend/src/lib/components/icons/DuckDbIcon.svelte b/frontend/src/lib/components/icons/DuckDbIcon.svelte index 1fa2e55b2c..793827c981 100644 --- a/frontend/src/lib/components/icons/DuckDbIcon.svelte +++ b/frontend/src/lib/components/icons/DuckDbIcon.svelte @@ -1,23 +1,32 @@ - + + diff --git a/frontend/src/lib/components/icons/DucklakeIcon.svelte b/frontend/src/lib/components/icons/DucklakeIcon.svelte index 488260ea1a..5665734ff5 100644 --- a/frontend/src/lib/components/icons/DucklakeIcon.svelte +++ b/frontend/src/lib/components/icons/DucklakeIcon.svelte @@ -1,4 +1,5 @@ + + interface Props { + height?: string + width?: string + } + + let { height = '24px', width = '24px' }: Props = $props() + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/DynatraceIcon.svelte b/frontend/src/lib/components/icons/DynatraceIcon.svelte index e8d57c58ce..df0ad401a4 100644 --- a/frontend/src/lib/components/icons/DynatraceIcon.svelte +++ b/frontend/src/lib/components/icons/DynatraceIcon.svelte @@ -1,31 +1,53 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/EdgeDbIcon.svelte b/frontend/src/lib/components/icons/EdgeDbIcon.svelte index cc31f328d4..19cb62e2b3 100644 --- a/frontend/src/lib/components/icons/EdgeDbIcon.svelte +++ b/frontend/src/lib/components/icons/EdgeDbIcon.svelte @@ -1,3 +1,6 @@ + - + - diff --git a/frontend/src/lib/components/icons/EnodeIcon.svelte b/frontend/src/lib/components/icons/EnodeIcon.svelte new file mode 100644 index 0000000000..91a5f59094 --- /dev/null +++ b/frontend/src/lib/components/icons/EnodeIcon.svelte @@ -0,0 +1,28 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/EventbriteIcon.svelte b/frontend/src/lib/components/icons/EventbriteIcon.svelte index f851a933f3..c98db1071e 100644 --- a/frontend/src/lib/components/icons/EventbriteIcon.svelte +++ b/frontend/src/lib/components/icons/EventbriteIcon.svelte @@ -1,15 +1,16 @@ - - - - - + + + diff --git a/frontend/src/lib/components/icons/ExaIcon.svelte b/frontend/src/lib/components/icons/ExaIcon.svelte new file mode 100644 index 0000000000..eef3057e92 --- /dev/null +++ b/frontend/src/lib/components/icons/ExaIcon.svelte @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/frontend/src/lib/components/icons/FaunadbIcon.svelte b/frontend/src/lib/components/icons/FaunadbIcon.svelte index 62c4dde4f2..7bb3f371e3 100644 --- a/frontend/src/lib/components/icons/FaunadbIcon.svelte +++ b/frontend/src/lib/components/icons/FaunadbIcon.svelte @@ -1,13 +1,15 @@ + - \ No newline at end of file diff --git a/frontend/src/lib/components/icons/FigmaIcon.svelte b/frontend/src/lib/components/icons/FigmaIcon.svelte index e31fe0a71d..bc39256ab2 100644 --- a/frontend/src/lib/components/icons/FigmaIcon.svelte +++ b/frontend/src/lib/components/icons/FigmaIcon.svelte @@ -1,12 +1,20 @@ + - - + + + + + + diff --git a/frontend/src/lib/components/icons/FirebaseIcon.svelte b/frontend/src/lib/components/icons/FirebaseIcon.svelte index 6d1ce00468..fd85fc72d2 100644 --- a/frontend/src/lib/components/icons/FirebaseIcon.svelte +++ b/frontend/src/lib/components/icons/FirebaseIcon.svelte @@ -1,10 +1,11 @@ + - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/FlyIcon.svelte b/frontend/src/lib/components/icons/FlyIcon.svelte index 02118212b2..d48b62556e 100644 --- a/frontend/src/lib/components/icons/FlyIcon.svelte +++ b/frontend/src/lib/components/icons/FlyIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/FormInputIcon.svelte b/frontend/src/lib/components/icons/FormInputIcon.svelte new file mode 100644 index 0000000000..1f1dcbc242 --- /dev/null +++ b/frontend/src/lib/components/icons/FormInputIcon.svelte @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/frontend/src/lib/components/icons/FormstackIcon.svelte b/frontend/src/lib/components/icons/FormstackIcon.svelte new file mode 100644 index 0000000000..e0da265e6a --- /dev/null +++ b/frontend/src/lib/components/icons/FormstackIcon.svelte @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FoxentryIcon.svelte b/frontend/src/lib/components/icons/FoxentryIcon.svelte new file mode 100644 index 0000000000..88f6b71054 --- /dev/null +++ b/frontend/src/lib/components/icons/FoxentryIcon.svelte @@ -0,0 +1,20 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/FreshdeskIcon.svelte b/frontend/src/lib/components/icons/FreshdeskIcon.svelte index 72f2d7b1fa..bbeb87d668 100644 --- a/frontend/src/lib/components/icons/FreshdeskIcon.svelte +++ b/frontend/src/lib/components/icons/FreshdeskIcon.svelte @@ -1,12 +1,19 @@ + - + diff --git a/frontend/src/lib/components/icons/FrontAppIcon.svelte b/frontend/src/lib/components/icons/FrontAppIcon.svelte index dfba63c8b7..aba4bccae4 100644 --- a/frontend/src/lib/components/icons/FrontAppIcon.svelte +++ b/frontend/src/lib/components/icons/FrontAppIcon.svelte @@ -1,12 +1,16 @@ - - + + + + diff --git a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte index 45a1821beb..0fb36804f6 100644 --- a/frontend/src/lib/components/icons/FunkwhaleIcon.svelte +++ b/frontend/src/lib/components/icons/FunkwhaleIcon.svelte @@ -1,73 +1,45 @@ + image/svg+xml + - - - - - - - - - - - - diff --git a/frontend/src/lib/components/icons/GCloudIcon.svelte b/frontend/src/lib/components/icons/GCloudIcon.svelte deleted file mode 100644 index 6fba8faa05..0000000000 --- a/frontend/src/lib/components/icons/GCloudIcon.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/frontend/src/lib/components/icons/GSheetsIcon.svelte b/frontend/src/lib/components/icons/GSheetsIcon.svelte index 419f9d1b26..b23010fd90 100644 --- a/frontend/src/lib/components/icons/GSheetsIcon.svelte +++ b/frontend/src/lib/components/icons/GSheetsIcon.svelte @@ -1,21 +1,69 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GcalIcon.svelte b/frontend/src/lib/components/icons/GcalIcon.svelte index 6b8f90b086..3a46c28c3e 100644 --- a/frontend/src/lib/components/icons/GcalIcon.svelte +++ b/frontend/src/lib/components/icons/GcalIcon.svelte @@ -1,21 +1,103 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdocsIcon.svelte b/frontend/src/lib/components/icons/GdocsIcon.svelte index 2e202cc2c8..c25121c2d3 100644 --- a/frontend/src/lib/components/icons/GdocsIcon.svelte +++ b/frontend/src/lib/components/icons/GdocsIcon.svelte @@ -1,14 +1,77 @@ - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GdriveIcon.svelte b/frontend/src/lib/components/icons/GdriveIcon.svelte index eff87a3645..70c5466cc0 100644 --- a/frontend/src/lib/components/icons/GdriveIcon.svelte +++ b/frontend/src/lib/components/icons/GdriveIcon.svelte @@ -1,21 +1,71 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GhostCmsIcon.svelte b/frontend/src/lib/components/icons/GhostCmsIcon.svelte index 018960d9b7..fcebf9f38f 100644 --- a/frontend/src/lib/components/icons/GhostCmsIcon.svelte +++ b/frontend/src/lib/components/icons/GhostCmsIcon.svelte @@ -1,12 +1,22 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GiphyIcon.svelte b/frontend/src/lib/components/icons/GiphyIcon.svelte index 521a358840..fc075eeac5 100644 --- a/frontend/src/lib/components/icons/GiphyIcon.svelte +++ b/frontend/src/lib/components/icons/GiphyIcon.svelte @@ -1,12 +1,17 @@ - - + + + + + + + diff --git a/frontend/src/lib/components/icons/GitBookIcon.svelte b/frontend/src/lib/components/icons/GitBookIcon.svelte index b310c868f7..c3d1c1cf52 100644 --- a/frontend/src/lib/components/icons/GitBookIcon.svelte +++ b/frontend/src/lib/components/icons/GitBookIcon.svelte @@ -1,12 +1,24 @@ - - + + + diff --git a/frontend/src/lib/components/icons/GitIcon.svelte b/frontend/src/lib/components/icons/GitIcon.svelte index 08f7631a91..c6dd709daa 100644 --- a/frontend/src/lib/components/icons/GitIcon.svelte +++ b/frontend/src/lib/components/icons/GitIcon.svelte @@ -1,3 +1,4 @@ + diff --git a/frontend/src/lib/components/icons/GithubIcon.svelte b/frontend/src/lib/components/icons/GithubIcon.svelte index 0b47c0d281..0bb14688cd 100644 --- a/frontend/src/lib/components/icons/GithubIcon.svelte +++ b/frontend/src/lib/components/icons/GithubIcon.svelte @@ -1,9 +1,10 @@ + interface Props { - height?: string; - width?: string; + height?: string + width?: string } - let { height = '24px', width = '24px' }: Props = $props(); + let { height = '24px', width = '24px' }: Props = $props() + + + diff --git a/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte new file mode 100644 index 0000000000..44bcd61411 --- /dev/null +++ b/frontend/src/lib/components/icons/GlobalForestWatchIcon.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/frontend/src/lib/components/icons/GmailIcon.svelte b/frontend/src/lib/components/icons/GmailIcon.svelte index 2a3281adef..cbcad5a889 100644 --- a/frontend/src/lib/components/icons/GmailIcon.svelte +++ b/frontend/src/lib/components/icons/GmailIcon.svelte @@ -1,21 +1,19 @@ - + + + + diff --git a/frontend/src/lib/components/icons/GoogleAiIcon.svelte b/frontend/src/lib/components/icons/GoogleAiIcon.svelte index af0e153f7b..9cae17abd8 100644 --- a/frontend/src/lib/components/icons/GoogleAiIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleAiIcon.svelte @@ -7,19 +7,21 @@ let { height = '24px', width = '24px' }: Props = $props() + - - - + + + + - - + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte index 5fb4e21797..61455feb1d 100644 --- a/frontend/src/lib/components/icons/GoogleCloudIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleCloudIcon.svelte @@ -1,14 +1,20 @@ + diff --git a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte index 0cccffa372..943f4c2cb0 100644 --- a/frontend/src/lib/components/icons/GoogleDriveIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleDriveIcon.svelte @@ -7,29 +7,68 @@ let { height = '24px', width = '24px' }: Props = $props() - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte index de25179dc4..3c43dab1f9 100644 --- a/frontend/src/lib/components/icons/GoogleFormsIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleFormsIcon.svelte @@ -1,31 +1,64 @@ + - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GoogleIcon.svelte b/frontend/src/lib/components/icons/GoogleIcon.svelte index bd53eb1bab..da75fb7a3b 100644 --- a/frontend/src/lib/components/icons/GoogleIcon.svelte +++ b/frontend/src/lib/components/icons/GoogleIcon.svelte @@ -1,36 +1,37 @@ + - - - - + diff --git a/frontend/src/lib/components/icons/GorgiasIcon.svelte b/frontend/src/lib/components/icons/GorgiasIcon.svelte new file mode 100644 index 0000000000..290ca62806 --- /dev/null +++ b/frontend/src/lib/components/icons/GorgiasIcon.svelte @@ -0,0 +1,23 @@ + + + + + + diff --git a/frontend/src/lib/components/icons/GpgKeyIcon.svelte b/frontend/src/lib/components/icons/GpgKeyIcon.svelte new file mode 100644 index 0000000000..86d11487ae --- /dev/null +++ b/frontend/src/lib/components/icons/GpgKeyIcon.svelte @@ -0,0 +1,30 @@ + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/GraphqlIcon.svelte b/frontend/src/lib/components/icons/GraphqlIcon.svelte index 292bdbad20..245cdc7b37 100644 --- a/frontend/src/lib/components/icons/GraphqlIcon.svelte +++ b/frontend/src/lib/components/icons/GraphqlIcon.svelte @@ -1,13 +1,15 @@ +
- + onClick={toggleSplit} + />
{/snippet} @@ -2442,27 +2455,33 @@ > {#snippet trailing()}
- - - - - + onClick={toggleSplit} + />
{/snippet} diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index efcae2af6a..fc8abd5d5c 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -14,6 +14,7 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import { Alert } from '$lib/components/common' import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' + import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { react18Template, react19Template, svelte5Template } from './templates' @@ -125,6 +126,9 @@ // would announce AI as unconfigured while it is merely unknown. Gate on the // config describing opWs, and load it here so the claim owns its own evidence. const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) + // Say where the button leads: the route hands this prompt to a fresh AI + // session for everyone who has one, and drives the docked chat for the rest. + const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) $effect(() => { @@ -398,8 +402,9 @@ }} />

- Leave empty to start with a blank template, or describe your app to get AI assistance - right away. + {handsOffToSession + ? 'Leave empty to start with a blank template, or describe your app to open an AI session that builds it.' + : 'Leave empty to start with a blank template, or describe your app to get AI assistance right away.'}

{/if} @@ -424,7 +429,7 @@ startIcon={{ icon: Sparkles }} btnClasses={AIBtnClasses('accent')} > - Start with AI + {handsOffToSession ? 'Start in AI session' : 'Start with AI'} {/if}
diff --git a/frontend/src/lib/components/resourceTypeDisplay.ts b/frontend/src/lib/components/resourceTypeDisplay.ts new file mode 100644 index 0000000000..db60f4279f --- /dev/null +++ b/frontend/src/lib/components/resourceTypeDisplay.ts @@ -0,0 +1,89 @@ +/** + * Resource types are named by abbreviation — `gdrive`, `gcal`, `s3` — so a product's real + * name ("Google Drive", "Amazon S3") only ever appears in its description. Matching on the + * name alone means searching "google" finds none of the Google integrations. + */ +export function resourceTypeSearchText(name: string, description?: string): string { + return description ? `${name} ${description}` : name +} + +function isWordStart(haystack: string, at: number): boolean { + return at === 0 || !/[a-z0-9]/.test(haystack[at - 1]) +} + +/** + * Sort key for one resource type against a search query, lowest first. + * + * Searching the description makes a query like "google" match a dozen types, most of which + * only mention the product in passing — so the ranking has to put a match on the type's own + * name above any description match, otherwise `googleai` lands below `anthropic`. + * Ties break on where the match starts: a description opening with the query describes the + * product, one mentioning it halfway through is an aside. + * + * Returns Number.MAX_SAFE_INTEGER when the query appears in neither field, so a caller + * matching more loosely than a substring (uFuzzy) keeps those results last instead of + * dropping them. + */ +export function resourceTypeMatchRank( + name: string, + description: string | undefined, + query: string +): number { + const q = query.trim().toLowerCase() + if (q === '') return 0 + + const n = name.toLowerCase() + if (n === q) return 0 + + const inName = n.indexOf(q) + if (inName >= 0) { + const tier = inName === 0 ? 1 : isWordStart(n, inName) ? 2 : 3 + return tier * 1e4 + Math.min(inName, 9999) + } + + const d = (description ?? '').toLowerCase() + const inDescription = d.indexOf(q) + if (inDescription < 0) return Number.MAX_SAFE_INTEGER + + const tier = isWordStart(d, inDescription) ? 4 : 5 + return tier * 1e4 + Math.min(inDescription, 9999) +} + +/** + * Rank-sorts resource types by how well they match `query`, keeping the incoming order + * for equal ranks (and for an empty query, where callers rely on their own ordering). + */ +export function sortResourceTypesByMatch( + items: T[], + query: string, + name: (item: T) => string, + description: (item: T) => string | undefined +): T[] { + if (query.trim() === '') return items + return items + .map((item, index) => ({ + item, + index, + rank: resourceTypeMatchRank(name(item), description(item), query) + })) + .sort((a, b) => a.rank - b.rank || a.index - b.index) + .map((entry) => entry.item) +} + +/** Human-facing label for a resource type: `git_repository` -> `Git repository`. */ +export function resourceTypeLabel(name: string): string { + const spaced = name.replace(/_/g, ' ') + return spaced.charAt(0).toUpperCase() + spaced.slice(1) +} + +/** "Add **a** Supabase resource" / "Add **an** Airtable resource". */ +export function resourceTypeArticle(name: string): string { + return /^[aeiou]/i.test(name) ? 'an' : 'a' +} + +/** Drawer title for creating a resource, named after its type once one is picked. */ +export function addResourceTitle(resourceType: string | undefined): string { + return resourceType + ? `Add ${resourceTypeArticle(resourceType)} ${resourceTypeLabel(resourceType)} resource` + : 'Add a resource' +} diff --git a/frontend/src/lib/components/resourceTypeMatchRank.test.ts b/frontend/src/lib/components/resourceTypeMatchRank.test.ts new file mode 100644 index 0000000000..9a764a44e0 --- /dev/null +++ b/frontend/src/lib/components/resourceTypeMatchRank.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest' +import { sortResourceTypesByMatch } from './resourceTypeDisplay' + +// Descriptions abridged from the hub. +const TYPES = [ + { + name: 'anthropic', + description: 'Anthropic API key for the Claude models, or via Google Vertex' + }, + { name: 'gcal', description: 'Google OAuth token authorizing the Google Calendar API.' }, + { name: 'googleai', description: 'API key for Google AI (Gemini), optionally on Vertex AI.' }, + { name: 'gmail', description: 'Google OAuth token authorizing the Gmail API.' }, + { name: 'mailchimp', description: 'Mailchimp API key.' } +] + +const sorted = (query: string) => + sortResourceTypesByMatch( + TYPES, + query, + (t) => t.name, + (t) => t.description + ).map((t) => t.name) + +describe('sortResourceTypesByMatch', () => { + it('ranks a match on the type name above any description match', () => { + expect(sorted('google')[0]).toBe('googleai') + }) + + it('ranks a description opening with the query above one mentioning it in passing', () => { + const order = sorted('google') + expect(order.indexOf('gcal')).toBeLessThan(order.indexOf('anthropic')) + }) + + it('ranks a name the query starts above one where it appears mid-word', () => { + const order = sorted('mail') + expect(order.indexOf('mailchimp')).toBeLessThan(order.indexOf('gmail')) + }) + + it('keeps the incoming order for an empty query', () => { + expect(sorted(' ')).toEqual(TYPES.map((t) => t.name)) + }) +}) diff --git a/frontend/src/lib/components/scriptEditor/LogPanel.svelte b/frontend/src/lib/components/scriptEditor/LogPanel.svelte index f253beadc7..010369a663 100644 --- a/frontend/src/lib/components/scriptEditor/LogPanel.svelte +++ b/frontend/src/lib/components/scriptEditor/LogPanel.svelte @@ -214,8 +214,12 @@ fixTableSizingToParent > {#snippet copilot_fix()} - {#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)} - + {@const previewError = + previewJob && !previewJob.success + ? getStringError(previewJob.result) + : undefined} + {#if lang && editor && diffEditor && args && previewError} + {/if} {/snippet} diff --git a/frontend/src/lib/components/search/GlobalSearchModal.svelte b/frontend/src/lib/components/search/GlobalSearchModal.svelte index d2b27635da..3df633d167 100644 --- a/frontend/src/lib/components/search/GlobalSearchModal.svelte +++ b/frontend/src/lib/components/search/GlobalSearchModal.svelte @@ -40,15 +40,13 @@ import { Alert } from '../common' import Popover from '../Popover.svelte' import Logs from 'lucide-svelte/icons/logs' - import { - AwsIcon, - AzureIcon, - GoogleCloudIcon, - KafkaIcon, - MqttIcon, - AmqpIcon, - NatsIcon - } from '../icons' + import AwsIcon from '../icons/AwsIcon.svelte' + import AzureIcon from '../icons/AzureIcon.svelte' + import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte' + import KafkaIcon from '../icons/KafkaIcon.svelte' + import MqttIcon from '../icons/MqttIcon.svelte' + import AmqpIcon from '../icons/AmqpIcon.svelte' + import NatsIcon from '../icons/NatsIcon.svelte' import RunsSearch from './RunsSearch.svelte' import AskAiButton from '../copilot/AskAiButton.svelte' diff --git a/frontend/src/lib/components/search/QuickMenuItem.svelte b/frontend/src/lib/components/search/QuickMenuItem.svelte index e3cffcf534..114fbf0d50 100644 --- a/frontend/src/lib/components/search/QuickMenuItem.svelte +++ b/frontend/src/lib/components/search/QuickMenuItem.svelte @@ -66,7 +66,6 @@ } else { kbdClass += ' !text-xs px-1.5' } - diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 22f1167455..00bf1c829e 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -10,6 +10,14 @@ /** Where inside the item the preview should open (a flow's `selected` * step). Steers the editor only — tab identity is (kind, path). */ previewParams?: Record + /** Pre-fills the new session's composer. Entry points that carry an intent + * (fix this error, run this item) hand it over as text rather than driving + * a chat the caller cannot see. */ + seedPrompt?: string + /** Send `seedPrompt` on arrival rather than parking it in the composer. + * For clicks that already stated the intent; leave it off where the prompt + * is a proposal the user should read first. */ + autoSend?: boolean } // A destination is either an editable item or a page, never both and never @@ -31,15 +39,17 @@ import { BROWSER } from 'esm-env' import AIButton from '$lib/components/copilot/chat/AIButton.svelte' import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' import { userStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' - import { openEditorInSession, openPageInSession } from './sessionSwitch.svelte' + import { openSourceInSession } from './sessionSwitch.svelte' let { source, btnClasses, btnProps, + label, + tooltip, fallback }: { /** Undefined (e.g. an item without a path yet) renders the fallback. */ @@ -48,9 +58,15 @@ /** Button styling overrides for hosts with their own conventions (an * editor toolbar). */ btnProps?: ComponentProps['btnProps'] - /** Rendered instead when the user opted out of the sessions beta - * (typically the editor's inline-chat toggle). Never rendered inside - * the session panel. */ + /** Names the action this replaced, for hosts whose button carried its own + * label ("AI Fix"). Defaults to AIButton's generic "Open in AI session". */ + label?: string + /** Hover text. Pass it whenever `label` is set: a renamed button no longer + * says that clicking it leaves for a session. */ + tooltip?: string + /** Rendered instead when the caller keeps a docked chat to drive — an + * opted-out user or an operator (typically the editor's inline-chat + * toggle). Never rendered inside the session panel. */ fallback?: Snippet } = $props() @@ -60,13 +76,11 @@ // SessionEditorTarget / the session wrapper); iframe preview tabs are not // the top window. const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top) - // The sessions page refuses operators, so an entry point on a page they can - // reach (Runs, the trigger lists) would only route them into that refusal. + // prefersSessionHandoff carries the operator clause: the sessions page refuses + // them, so an entry point on a page they can reach (Runs, the trigger lists) + // would only route them into that refusal. const show = $derived( - !inSessionPanel && - !!(source?.target || source?.page) && - !$userStore?.operator && - isGlobalAiEnabled() + !inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff($userStore?.operator) ) // Not $state: only read inside open() as a re-entrancy latch, never rendered. @@ -75,16 +89,11 @@ if (opening || !source) return opening = true try { - // `beforeOpen` persists what is on screen and throws when it could not, so a - // failure has to stay on this page and say so — the session would otherwise - // open on an older draft than the editor the user is looking at. - await source.beforeOpen?.() - if (source.target) { - await openEditorInSession(source.target, source.workspaceId, source.previewParams) - } else { - const href = source.page?.() - if (href) await openPageInSession(href, source.workspaceId) - } + // `beforeOpen` (run inside openSourceInSession) persists what is on screen + // and throws when it could not, so a failure has to stay on this page and + // say so — the session would otherwise open on an older draft than the + // editor the user is looking at. + await openSourceInSession(source) } catch (e) { sendUserToast(e instanceof Error ? e.message : String(e), true) } finally { @@ -94,7 +103,13 @@ {#if show} - + {:else if !inSessionPanel} {@render fallback?.()} {/if} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index 9472819eea..52d9e476fb 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -1,5 +1,5 @@ + +
+
+
+

Icon library

+

+ Every component in lib/components/icons. Each tile renders the + icon on Windmill's light surface and, when enabled, its dark surface — so a monochrome + icon's light/dark pair can be checked side by side without switching the app theme. An icon + only differs between the two halves if it inherits currentColor + or carries a dark: class; hardcoded fills look the same on both. +

+
+ +
+
+ +
+
+