mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
release-2.7.0 (#1264)
* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder * chore: run format * chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162) Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg). Updates `@types/pg` from 8.20.0 to 8.20.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/pg) --- updated-dependencies: - dependency-name: "@types/pg" dependency-version: 8.20.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-minor-updates group with 4 updates (#1163) Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `react-hook-form` from 7.79.0 to 7.84.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0) Updates `react-icons` from 5.6.0 to 5.7.0 - [Release notes](https://github.com/react-icons/react-icons/releases) - [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0) Updates `typescript-eslint` from 8.61.1 to 8.66.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint) Updates `vite` from 8.0.16 to 8.2.0 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/create-vite@8.2.0/packages/vite) --- updated-dependencies: - dependency-name: react-hook-form dependency-version: 7.84.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-icons dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.66.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: vite dependency-version: 8.2.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 3 updates (#1164) Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid). Updates `jose` from 6.2.7 to 6.2.8 - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v6.2.7...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `nanoid` from 6.0.0 to 6.0.1 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1) --- updated-dependencies: - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 6.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump undici in the prod-minor-updates group (#1165) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the major-updates group with 2 updates (#1166) Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom). Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) --- updated-dependencies: - dependency-name: "@types/better-sqlite3" dependency-version: 9.6.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: stop resyncAutoIncrement failing on tables without an id column (#1173) The Postgres branch asked pg_get_serial_sequence(table, 'id') about every table a fixture had inserted into. That function raises 42703 when the column does not exist, rather than returning null, so any seed touching a table keyed on something else took down the fixture. host_sidebar_preferences is keyed on user_id and has no id at all, which is why the Postgres job on dev-2.7.0 fails for every pull request. Drive the lookup from information_schema so a missing id column yields no row instead of an error. A text primary key still returns a null sequence and is still skipped, as before. * chore: install the git hooks that were already configured (#1174) husky, lint-staged, commitlint and their config have been in the repo since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg runs commitlint, the lint-staged globs are in package.json and the commitlint rules in .commitlintrc.json. None of it has ever run. husky only takes effect once it sets core.hooksPath, and that happens in the prepare lifecycle script, which the package did not define -- so every clone installed the tooling and left the hooks unwired. That is why formatting keeps failing in CI rather than locally: three of the four open pull requests fail lint-and-build on prettier alone, touching between one and five files each, and the check is the first place anyone finds out. prepare falls back to true so a checkout without a .git directory cannot break installation. The Docker build passes --ignore-scripts, so it never runs this at all. Also pin the Prettier extension to the repo's own copy via prettier.prettierPath, and let .vscode/settings.json out of .gitignore so it applies to everyone. The extension bundles its own prettier otherwise, which formats to a different version's rules than the one CI enforces. * fix: derive the ssh_credentials rebuild from the live schema (#1172) The startup rebuild that drops the old username NOT NULL constraint restated the table's columns as a literal and then copied rows with INSERT INTO temp SELECT <every live column>. The table has gained columns since that literal was written — cert_public_key, pin, sort_order and sync_id are all added by addColumnIfNotExists before the rebuild runs — so the destination was narrower than the source. SQLite rejected the INSERT on a column count mismatch, the error was swallowed as a warning, and the constraint survived every restart. Read the CREATE TABLE statement back from sqlite_master and rewrite just the table name and the username constraint, so the replacement table cannot fall behind the real one. Copy rows by explicit column name rather than positionally, and replay the table's indexes, which DROP TABLE would otherwise take with it along with the sync_id uniqueness. * fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171) The audit trail is meant to outlive the account it belongs to: deleting a user nulls user_id and keeps username for attribution. schema.ts, the Drizzle migrations and AuditLogRepository.anonymizeByUserId were all written against that, but the runtime bootstrap still created user_id TEXT NOT NULL. A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the correct nullable column, but it can never run — the primary bootstrap has already created the table, so IF NOT EXISTS is a no-op. Every fresh install therefore got the old constraint, and user deletion failed with "NOT NULL constraint failed: audit_logs.user_id" for any account that had logged in at least once, via both the admin delete path and the OIDC account-link cleanup. Fix the primary bootstrap, and rebuild the table on existing databases using the same pattern already used for ssh_credentials.username, since SQLite cannot ALTER a column. * fix: key the sync upsert on the row it just looked up (#1175) A sync push locates the stored row twice -- once to decide insert vs update, once to write it -- and the two lookups were spelled out separately. Only the read knew about singleton entities; the write always keyed on table.id. userPreferences is the only singleton, and user_preferences is the one synced table with no id column: its primary key is user_id. table.id was therefore undefined, and drizzle emitted a comparison with nothing on its left: ( = ? and "user_preferences"."user_id" = ?) The insert branch was unaffected, so the first push of preferences succeeded and every push after it -- the steady state -- failed with SqliteError: near "=": syntax error. Preference sync never converged, and both sides ship the same handler, so the desktop's embedded backend failed identically. Extract the lookup into locateSyncRow() and use it for the read, the update and the tombstone delete, so the three cannot drift apart again. The tombstone path already handled singletons correctly; it now shares the one expression rather than keeping a third copy of it. * fix: refuse an SSH connection whose host id resolves elsewhere (#1176) A client identifies a host by the numeric row id of the database it is displaying. With the desktop connection origin set to "Remote server", that id is resolved against the sync server's ssh_data instead, and the two autoincrement sequences need not line up -- they diverge as soon as each side accumulates inserts and deletes in a different order. resolveHostById() then returns whichever row owns that id here, and the handler takes the address, the credentials, the jump hosts and the stored host key from it. The session opens on a machine the user did not pick, while the host list, host details and export all keep showing the right one. Commands run on the wrong server, a host key mismatch is reported for the wrong reason, and anything typed at the prompt goes to the wrong place. Compare the resolved address against the one the client sent, and refuse when they disagree. Checking at the point the row is loaded covers every use of it rather than each site separately. Addresses are compared with brackets stripped and casing folded, so an IPv6 literal or a hostname written differently is not treated as a different machine; when the server has no address stored, the client's own details are used as before. This stops the wrong-machine session. It does not make delegated connections work when the ids have drifted -- that needs the host to be addressed by syncId across the boundary, which the connection protocol does not currently carry. * fix: refuse SFTP and Docker console on a mismatched host id too (#1177) The wrong-machine guard added for SSH covered one of the paths that resolve a client-supplied host id against this server's ssh_data. The file manager and the Docker console take the same id from the same client and dial whatever row owns it here. The file manager then browses, edits and deletes files on that machine, and the Docker console attaches to its daemon -- both while the UI shows the host the user actually picked. Reuse hostAddressMismatch at each point the row is loaded. The two file manager sites sit inside "failed to resolve credentials, carry on" handlers, so the refusal is a distinct error type those catches rethrow; swallowing it would resume the connection this is meant to stop. The Docker console reports over its socket, as it does for every other refusal. The user-facing wording now lives next to the check instead of being written out at each site. Still uncovered, and not fixable this way: file-manager's transfer session, jump-host-chain and the proxmox routes resolve an id with no client-supplied address to compare it against. Those need the host to be addressed by syncId across the boundary. * feat: address hosts by syncId when a connection is delegated (#1178) A numeric host id belongs to the database that produced it. The desktop app lists hosts from its embedded database and names them by row id, so when a connection is delegated to a sync server that id is resolved against a different table, whose autoincrement sequence has no reason to agree. The row it lands on is a different machine, and it supplies the address, the credentials, the jump hosts and the stored host key. #1176 and #1177 made that refuse rather than connect. Refusing is right, but it leaves "Remote server" unusable once the ids have drifted, which is the state the reporter was in. syncId already names a host identically on both sides -- remote sync relies on it, ssh_data.sync_id is unique, and the API already returns it. It just never reached the backend: hostToSSHHost() builds its result field by field and dropped it. Carry it through, and resolve with it when it is present: resolveHostBySyncId(syncId, userId) // translate, then reuse -> findHostIdBySyncId(syncId) // this database's own row id -> resolveHostById(hostId, userId) // permissions, decryption, audit The translation is deliberately not scoped to a user -- sync_id is unique across the table and a shared host belongs to someone else -- so access stays with the permission check in the id-based path, which the new tests cover. An unknown syncId resolves to nothing rather than falling back to the numeric id: an unknown host is precisely where guessing picks the wrong machine. Clients that send no syncId are unchanged, address comparison included, so an older desktop keeps its safety net instead of breaking. * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep already-shared hosts sharing their SSH authentication (#1179) Sharing a host used to hand the owner's SSH authentication to the recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth, added as NOT NULL DEFAULT 0. Existing rows took the default, so every host shared before the upgrade stopped supplying credentials the moment the column appeared. The snapshot in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing was captured; resolveRecipientSharedHostAuthentication() then fell through to "required" and the recipient got "No valid authentication method provided" on a host that had worked the day before. Downgrading to 2.6.0 restored it, since that code has no such column to consult. Backfill the flag for hosts that already appear in host_access. That is where the previous behaviour was in effect and where the owner had already agreed to share; hosts nobody has shared keep the new default and stay off until their owner shares them. Guarded by a settings key so it runs once. Without that, an owner who turns sharing back off would have it turned on again by the next restart. * fix: let a single credential disable 2FA again (#1180) The disable dialog has one field, labelled "Enter TOTP code or password", and its caller passes that value as disableTOTP(input) -- so it arrives as `password` with `totp_code` undefined. That call has been unchanged since v2.3.0. 2.5.1 changed the route to require both: if (!totp_code || (!userRecord.isOidc && !password)) -> 400 replacing `const credential = password || totp_code`. The first check has rejected every attempt since, whatever the user typed, so nobody has been able to turn 2FA off -- the client reports the generic "Failed to disable 2FA", which hides which check failed. Take one credential again and try it as a TOTP code, a backup code, then the account password. verifyTotpReauth still refuses the password itself, so that comparison stays in the route; an OIDC user has no password hash and reaches neither. The backup-codes route has the same shape but no caller in the UI -- its codes are returned when TOTP is enabled -- so it is left alone rather than changed blind. * fix: attach user-managed CA certificates over SFTP too (#1181) opkssh-cert-auth.ts exports two helpers that end in the same _applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for user-managed CA-signed -cert.pub files. The file manager called the first one twice and the second one never. So a host whose key is paired with a CA-signed certificate authenticated in a terminal and failed over SFTP, while OPKSSH certificates -- going through the other helper -- worked in both. The file manager was not missing certificate support in general; it was missing one of the two paths into it. The connection also never carried the certificate to begin with: cert_public_key was not among the fields copied into resolvedCredentials, so both places that build an SFTP connection now read it and attach it where the private key is prepared -- the dedicated transfer session and the main connect route. An unusable certificate is logged and skipped rather than failing the connection. The key alone may still be accepted, which is what happened while this was not wired up at all, and turning that into a hard failure would break setups that currently work. Reported in #1160 with the call-site asymmetry already traced; the reporter noted they could not confirm the link to their failure, having moved off SSH CAs. The asymmetry is real either way and reproduces the symptom exactly. * fix: authenticate the desktop Docker console WebSocket (#1182) The console WS opted out of the query token: buildOriginWsUrl({ ..., includeLocalJwt: false }) leaving it with no credential at all on the desktop. The browser WebSocket API cannot set an Authorization header, and while Electron's main process injects a remembered JWT cookie, it requires an exact origin match -- the cookie belongs to the API origin (localhost:30001) while the console connects to 127.0.0.1:30009, so nothing is attached. The backend then closes the handshake with 1008 before it logs anything, which is why the log has no docker-console entries while stats and logs polling keep succeeding on the same host. The web build is unaffected: it connects same-origin and its cookie is sent normally. Drop the opt-out so the console carries the local JWT like the SSH terminal does -- the same token, the same query parameter, and the backend already reads it there. Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always resolve to "remote", so that call never reaches the local branch. * fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: use getClientIp in getRequestMeta for correct audit-log IPs getRequestMeta had near-duplicate, strictly worse forwarded-header logic: the array branch didn't split/trim, there was no socket-peer fallback, and it returned "" instead of "unknown". Delegate to getClientIp so the audit trail gets the same correctness as the terminal login-alert path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff (#1170) * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * feat: add terminal image handoff Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar. * docs: document terminal image handoff deployment --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167) * fix(desktop): stop suppressing the update prompt for users who need it The startup update modal stored its dismissal under the local app version rather than the remote version being offered, and the up-to-date branch wrote that key with no user interaction at all. A user who launched while current had their own version recorded; once the next release shipped, `dismissedVersion === currentVersion` still held and the modal was skipped on every launch. It reappeared only after the user had already updated -- the inverse of what it is for. Present since v2.3.0. Key the dismissal on the offered remote version instead. The change is backward compatible: an existing key holding 2.6.0 compares unequal against a remote 2.6.1, so affected installs are prompted on their next launch. When the check itself fails there is no remote version, so nothing is recorded and no future prompt is suppressed. That left the version badge as the only remaining signal, and it was an inert span on both surfaces that render it -- the profile panel and the dashboard stats bar -- even though the `getVersionInfo()` response it is built from already carries `latest_release.html_url`. Extract the duplicated badge into `components/version-badge.tsx` and make the update case a link to the release, with an accessible name that says where it goes. The beta and stable cases stay inert. `getVersionInfo()` returned `Record<string, unknown>`, so the release URL was unreachable without a cast; give it a `VersionInfo` type that keeps an index signature, since `SystemOverviewWidget` reads `updateAvailable` off the same response. * test: cover the read that actually reaches the badge The extracted VersionBadge is unit-tested, but the line that decides whether it ever receives a URL -- pulling `latest_release.html_url` out of the version response -- was duplicated at both call sites and asserted nowhere. A wrong property there compiles (the response type keeps an index signature) and every existing test still passes. Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the happy path, a response with no release, a release with no URL, and a missing response, since the caller's fetch can reject. Empty string is the contract the badge reads as "nothing to link to", so it stays an inert span rather than rendering a dead anchor. * docs: state the index signature's real reason The comment claimed the version endpoint carries fields beyond the typed ones, citing `updateAvailable`. It does not -- `GET /version` returns status, localVersion, version, remoteVersion, latest_release, cached and cache_age, and nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless, which is why the permissive index signature has to stay, but that is a stale read rather than an undocumented field. Say so accurately. * Send alerts in Discord channels with Webhooks (#1158) * feat(utils): add discord webhook sender Add a utility to send alert embeds to Discord webhooks. * fix(utils): validate DNS and use global fetch for outbound requests Prevent private destination access and rely on global fetch after DNS validation. * chore(logger): include extra context in logs Show additional sanitized context entries for clearer diagnostics. * feat(alerts): support discord channel type in routes and engine Accept discord channels and route alerts to the Discord sender. * feat(ui): add Discord option to notification channel dialog Allow creating/editing Discord webhook channels with username/avatar. * fix(ui/api): accept structured config payload for notification channels Allow the client to pass structured config objects (or strings) when creating/updating channels. * chore: sync Crowdin translations * fix(homepage): make the System Overview update indicator able to fire (#1168) The widget's "Update available" row and orange version text were unreachable, for two independent reasons that each alone would have been enough. It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch, no remote version, nothing to compare. It then read `info.updateAvailable`, a field the route does not return in either mode; the success response carries status, localVersion, version, remoteVersion, latest_release, cached and cache_age. `Boolean(undefined)` is false, always. The read type-checked only because `getVersionInfo()` is declared as `Record<string, unknown>`, so a property name that does not exist is indistinguishable from one that does. Let the endpoint do the comparison and read `status === "requires_update"`, which is what the dashboard stats bar and the profile panel badge already do. The row's label was `homepage.overviewUpdate`, whose English string is "Up to date" -- as the label of an update-available row it read "Up to date / Update available". Nobody has seen that, because the row has never rendered; fixing the indicator without the label would have shipped it. Give it its own key. That leaves `homepage.overviewUpdate` unused; it is left in place rather than removed, since it would be the natural value for an always-visible row and that is a product decision, not part of this fix. * fix: capture real client IP for SSH login alerts behind reverse proxy (#1169) * fix: capture real client IP for SSH login alerts behind reverse proxy The WebSocket terminal handler used req.socket.remoteAddress for the "user logged in" alert message, which is the immediate TCP peer (the reverse proxy) rather than the actual client IP forwarded via X-Forwarded-For. This made trust-proxy config on Traefik irrelevant since Termix never read the header for this code path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: cover getClientIp forwarded-header and socket fallback paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * chore: add url to SENSITIVE_FIELDS for discord url * fix: enforce SSRF protection on outbound fetches Use `undici.fetch` with the custom DNS lookup hook to ensure the validated DNS resolution is the one used for the connection. Fix DNS lookup/address validation bugs and add coverage for private, public and invalid addresses, including the resolution issue affecting Discord endpoints. * chore: prettier format * fix: validate all DNS addresses and close dispatcher * fix DNS lookup validation and callback handling * update safe outbound fetch tests * ensure created dispatcher is properly closed * chore: remode url from SENSITIVE_FIELDS for other logs --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * fix tmux UTF-8 path handling (#1157) Co-authored-by: Carl <scarlettme@qq.com> * chore: update package lock * chore: update gitnore * fix: [BUG] (#1049) https://github.com/Termix-SSH/Support/issues/1049 * fix: test commitlint path fix (#1021) * fix: SGR mouse-tracking escape codes printed as text (#1023) * fix: quote $1 in commit-msg hook so it works from git worktrees * fix: [BUG] could not connect to the database (#1057) https://github.com/Termix-SSH/Support/issues/1057 * fix: [BUG] VNC connect macOS screen sharing failed (#1063) https://github.com/Termix-SSH/Support/issues/1063 * fix: [BUG] Meta key (#1075) https://github.com/Termix-SSH/Support/issues/1075 * fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085) https://github.com/Termix-SSH/Support/issues/1085 * fix: [BUG] webhook not working (#1080) https://github.com/Termix-SSH/Support/issues/1080 * fix: [BUG] First server sync doesn't refresh UI (#1084) https://github.com/Termix-SSH/Support/issues/1084 * fix: [BUG] How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: [BUG] Sudo Password Auto-fill Persistance (#1098) https://github.com/Termix-SSH/Support/issues/1098 * feat: [FEATURE] Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * feat: [FEATURE] (#1055) https://github.com/Termix-SSH/Support/issues/1055 * feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013) https://github.com/Termix-SSH/Support/issues/1013 * feat: [FEATURE] Allow paste on non https (#1026) https://github.com/Termix-SSH/Support/issues/1026 * feat: be-azerty layout (#1073) https://github.com/Termix-SSH/Support/issues/1073 * feat: Keyboard shortcuts to move between open tabs (#1069) https://github.com/Termix-SSH/Support/issues/1069 * feat: Session Logs as a downloadable text file (#1058) https://github.com/Termix-SSH/Support/issues/1058 * fix: persist and auto-fill saved SSH and sudo passwords * fix: persist docker runtime selection and docker manager UI issues * feat: Allow excluding specific mounts from disk usage metrics (#1046) https://github.com/Termix-SSH/Support/issues/1046 * feat: Expand Snippets Function (#1031) https://github.com/Termix-SSH/Support/issues/1031 * chore: restore the prettier baseline on dev-2.7.0 (#1185) Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .` fails and takes lint-and-build with it — on every pull request, whatever it changes. Formatting only, produced by `npx prettier --write` on exactly the files the check names. No logic touched: tsc passes for both configs, backend 148 files / 1106 tests and UI 71 files / 479 tests all pass. * test: keep the tmux escaping test runnable on Windows (#1184) The escaping check ran its command through /bin/sh. That binary does not exist on Windows, and Windows is a supported platform for the desktop app, so `npm test` fails there on a test about string quoting. CI is ubuntu-only and would never see it. Assert the escaped string directly, which covers the rule on every platform, and keep the round trip through a real shell as a separate case guarded by platform -- it is the stronger evidence where a shell exists. * chore: drop the unreachable table probes from migrateSchema (#1186) Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap had already created earlier in the same startup. The probe could not throw, so the catch never ran. Two of those unreachable copies had drifted from the definition actually in use. sessions had lost ON DELETE CASCADE, and session_recordings still carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username -- the shape from before audit trails were made to outlive the account. They would have taken effect had anything ever reordered startup. Kept, because they are not the same thing: - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT EXISTS is a no-op on a table that exists, so a database created before a column was added still needs the ALTER. Those probe a column, not a table. - blocks that are a table's only creation point. - the user_open_tabs block, which is a data migration; its SELECT is a precondition, not a probe. Deletion only, no behaviour change. * fix: repair the frontend type-check and clear the 299 errors behind it (#1189) * fix: repair frontend type-check configuration and the errors it exposed The root tsconfig.json is solution-style with "files": [], so the `npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types have therefore never been checked, and 299 errors had accumulated behind that no-op. This clears just over half of them; nothing here changes runtime behaviour. Configuration: - "@/types" resolved through the "@/*" fallback to src/ui/types, which does not exist. Added an explicit mapping to src/types/index.ts. - src/vite-env.d.ts sits outside the include list, so import.meta.env and the ?url import suffix were unknown. Added. - src/ui/types/ held a single file, keybindings.ts, while every other shared type lives in src/types/. Six modules imported it as "@/types/keybindings" and silently resolved to nothing. Moved. Type definitions that had fallen behind the code: - guacamoleConfig and terminalConfig were Record<string, unknown> in ui-types while the editor read concrete fields off them. Both now use the real interfaces; GuacamoleConfig is extracted from its inline definition in guacamole-api.ts so the two cannot drift again. - customThemeColors and TerminalTheme["colors"] described the same object with different optionality. Aligned. - FileWindow declared its own SSHHost whose authType was "password" | "key", which no longer matches the eight the app supports. - connectSSH and listSSHFiles returned Record<string, unknown>, so every field the callers destructured arrived as unknown. - AxiosRequestConfig and AxiosResponse were used without being imported. Also adds asHttpError() for the handful of catch blocks that reached into an unknown binding, and narrows the Host | HostFolder comparator and the RailItem union at the points where the discriminant was not carrying. Note: dbHealthMonitor.reportDatabaseError was being called with a second argument it does not accept, so the authenticated-or-not flag was already being discarded at runtime. Dropped the argument to match the signature; whether that flag was meant to gate the report is worth a separate look. * fix: clear more of the frontend type-check baseline Continues the previous commit; 140 errors down to 70. Three of these were real defects rather than missing annotations. Defects: - DashboardTab counted active tunnels by comparing status to "CONNECTED", but CONNECTION_STATES.CONNECTED is "connected" and that is what the tunnel manager emits, so the count was always zero. Now compares against the constant. - QuickActionsCard requires isAdmin and gates a block of admin-only actions on it, but neither call site passed it — those actions never rendered. Both call sites also passed isAdmin to HostStatusCard, which does not accept it; the prop had evidently been moved and the call sites missed. - The host editor stores jump host ids as strings and sent them straight to an API typed for numbers. Backend host lookups compare against an integer column, which a string does not match on Postgres or MySQL. Converted. Types brought in line with the data: - Host and HostData were missing hasPassword, hasSudoPassword, sortOrder, instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault" authType; TabContextTab was missing the "tunnel" tab, which TabContext already branched on. - statsConfig and terminalConfig used inline shapes that had drifted from StatsConfig and TerminalConfig. Both now reference the real interfaces; excludedMounts, which the editor reads, was added to StatsConfig. - downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all returned Record<string, unknown> while callers read named fields. - The Guacamole declarations were missing Keyboard.reset, Client.onfile, InputStream.sendAck, Status.Code and BlobReader, all already in use. - NetworkTopologyNode/Edge could not be discriminated, though the graph code tells them apart by testing for source/target. ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor writes the string spellings while proxy-helper.ts tests for "http" and casts everything else to 4|5 before handing it to the socks client, so a chained proxy reaches it as "socks5" rather than 5. Typed as what is actually stored; reconciling the two spellings needs a migration decision and is left alone here. * fix: continue clearing the frontend type-check baseline 70 errors down to 44. Dead configuration removed: - Terminal set terminal.options.bellStyle on xterm, which dropped the option in v5. The host editor still exposes the setting and stores it; it has simply had no effect on the terminal since that upgrade. Making the bell work again means handling the onBell event and is left alone. - CodeEditor passed scrollPastEnd to basicSetup, which has no such option. - FileManager passed an id to openWindow, which assigns its own and discards what it is given — the component was already being rendered under a different id than the one the caller held. Widgets that were registered but unreachable: - DockerActivityWidget and SshQuickConnectWidget register under "docker_activity" and "ssh_quick_connect", neither of which was in WidgetTypeId, and both referenced config interfaces that did not exist. Added the ids and the two interfaces, inferred from their edit forms and defaultConfig. More endpoints given their real return types: getRecentFiles, getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile, copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets. parseGuacamoleConfig() handles the host row carrying guacamoleConfig either parsed or as raw JSON, which GuacamoleApp was reading fields off directly. TerminalHostConfig was missing name, which it reads for the activity log. * fix: continue clearing the frontend type-check baseline 44 errors down to 17. Host and AuditLog are now type aliases rather than interfaces. An interface has no implicit index signature, so neither could be assigned to the `[key: string]: unknown` shapes that TerminalHostConfig, HostMetricsTab's HostConfig and several helpers declare — eight errors came from that alone. More dead configuration: - i18n passed checkWhitelist to the language detector, which no longer has that option; supportedLngs already covers it. - SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor. - AudioPreview's onLoadedMetadata never fired: react-h5-audio-player spells the prop onLoadedMetaData. - MarkdownRenderer destructured `inline` from code(), which react-markdown removed in v9, so the flag was always undefined and every inline span took the block branch when it happened to carry a language class. Now derived from whether a className is present at all. - SnippetsPanel put a title prop on a lucide icon, which does not forward it; changed to aria-label so the hint is actually reachable. updateHostConfig in TabContext replaced tab.hostConfig wholesale with the six-field literal it receives, dropping everything else the tab held about the host. It now merges onto the existing config. Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return types (UpdateLog kept private copies of two of them, and VersionInfo was missing `version`, which the endpoint sends and the panel renders); wakeOnLan and vncCredentialId get the numeric ids they are typed for; and the tmux formatter takes i18next's TFunction instead of a hand-written signature it does not satisfy. * fix: clear the last frontend type errors and make CI actually run the check Baseline is now zero, so the check can be turned on. `npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles nothing: the root tsconfig.json is solution-style with "files": [], and plain tsc does not follow project references. Both are now `tsc -b`, which builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type error and watching the command fail. Last defects in this batch: - patchOpenTab could not carry hostId, so quick-connect's "save this host and attach the tab to it" call was passing a field excluded from the type all the way down. The column exists and updateForUser spreads whatever it receives, so the write worked; only the types disagreed. Widened front to back. - The file-comparison window opened without x, y, width or height — every other openWindow call passes them — and sent a `type` field WindowInstance does not have. - HostEditor gated a block on authType === "warpgate", which is not one of the eight authType values. Unreachable, and it held only a label and a description. Removed. - FileManager passed onLoadDirectory to a sidebar that neither declares nor reads it, and FileManagerApp passed embedded to a FileManager that has no such prop. - TunnelApp's minimal Host was missing three required flags. The remainder were assertions at boundaries that are genuinely loose: bulk host import takes rows assembled from untyped input and validates them server-side, and a vi.fn() whose body only throws infers never. * feat: add drive file browser and drag-and-drop upload for RDP (#1187) Drive redirection could already be enabled per host, but the redirected drive lived inside guacd with no way to reach it from the browser: the client never handled onfilesystem, so the mounted volume was writable from Windows and invisible from Termix. Add a file browser panel that lists the drive, downloads files, and uploads them, plus drag-and-drop onto the display which opens the panel and uploads into the directory currently shown. The disable-upload and disable-download connection settings are honoured by the UI, not just passed to guacd. A rejected upload stops the BlobWriter without firing onerror or oncomplete, so the error ack is watched explicitly; otherwise the transfer would hang forever. Directory reads carry a deadline for the same reason. Also declares Guacamole.Object, Client.onfilesystem, BlobReader and BlobWriter in the local type definitions, which previously omitted them. * fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190) Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102). 2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a three-way branch on touchMode, and the touch branches replace the mouse binding instead of adding to it: if (touchMode === "touchscreen") new Guacamole.Mouse.Touchscreen(el) else if (touchMode === "touchpad") new Guacamole.Mouse.Touchpad(el) else new Guacamole.Mouse(el) The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/ mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/ touchend. So in a touch mode nothing is listening for the mouse at all. touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0, which is true of every laptop with a touchscreen — machines that are still driven by a mouse. Those users lost the pointer entirely while the keyboard kept working, because Guacamole.Keyboard is bound independently. The physical pointer is now always bound and a touch emulator is layered on top when one is selected. Extracted to bindPointerInput() so the binding is testable; the test fails against the old branch. Note the issue also carries a second, unrelated report where well-formed mouse frames do reach guacd and the VNC leg ignores them. That one is not this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it is not an upgrade either. * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191) * chore: sync Crowdin translations * fix: deduplicate /api/folders requests to prevent intermittent folder disappearance getSSHFolders() had no request deduplication while getSSHHosts() used a TTL cache with in-flight dedupe. When loadHosts() fired multiple times during rapid navigation between Credentials and Hosts panels, the folder response could arrive after the hosts response, causing the sidebar tree to render without folder metadata. - Add foldersCache (10s TTL) in hosts-request-cache.ts - Wrap getSSHFolders() API call in getCachedSSHFolders() - Invalidate folders cache on renameFolder, updateFolderMetadata, deleteAllHostsInFolder, and renameCredentialFolder - Include foldersCache in invalidateHostsAndStatusCaches() Closes Termix-SSH/Support#1103 Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> --------- Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195) * chore: sync Crowdin translations * chore(deps): bump undici in the prod-minor-updates group Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.9.0 to 8.10.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.9.0...v8.10.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.10.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: proxmox metrics integration * feat: add folder select to the host multi select feature * feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc * feat: made toolbar open file manager at path * fix: delete folder route not invalidating host list cache * fix: match host list icons with tab bar iconfix * fix: change sidebar reset button icon to seperate against fullscreen button * feat: unify connection system and add connection logs to guacd hosts * fix: make mobile terminal scrollback match xterm wheel behavior (#1198) * fix: route mobile terminal scrolling through xterm viewport * docs: document mobile terminal touch scrolling * chore: add a note to not place files in docs * chore: remove touch imput from docs * feat: improve snippet system with variable snippets and collapse settings * feat: new fleet system with snippet, packages, files, and inventory features * fix: command pallete not loading new activity and made enter load first item * feat: add subhost from parent host organization feature * feat: add workspaces feature to save tab layout * perf: greatly improved performance across metrics polling and host management for enterprise users * feat: add a onboarding system with a new interface simplicity system * feat: finalize the multi dialect database system * fix: bind trusted MFA devices to client installs (#1202) * fix: merge OIDC group claims across sources (#1203) * fix: allow disabling SSH keepalives (#1204) * fix: distinguish reachable and available hosts (#1206) * fix: throttle session activity persistence (#1207) * fix: preserve saved RDP connection settings (#1208) * fix: authenticate tunnel status stream (#1209) * fix: select quick-created credentials (#1210) * fix: stagger initial metrics collection (#1211) * fix: stagger initial metrics collection * fix: admit reachable hosts to initial metrics * fix: prevent long host names shifting dashboard metrics (#1205) * feat: add global touch input settings (#1201) Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: keep host list row sizing stable (#1213) * fix(guacamole): correct Windows key mapping (#1216) * fix: normalize OIDC discovery issuer URLs (#1218) * fix: prompt for RDP domain credentials (#1212) * fix: route status checks by connection origin (#1214) * fix: restore desktop Tailscale configuration (#1215) * fix(docker): restore Node 24 for ssh2 native crypto (#1217) * feat: added new automations feature with events, channels, and steps * feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar * feat: expand onboarding process with more customization/features * feat: initial implementation of the termix ai feature * chore: run linter * fix: issue #424 (#424) https://github.com/Termix-SSH/Support/issues/424 * fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133) https://github.com/Termix-SSH/Support/issues/1133 * fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109) https://github.com/Termix-SSH/Support/issues/1109 * feat: How to enable SSL using custom certificate (#1083) https://github.com/Termix-SSH/Support/issues/1083 * fix: show profile API key after creation (#1221) * feat: add trusted proxy authentication (#1222) * fix: clarify SSH agent authentication (#1224) * feat: add first-class split screen tabs (#1226) * feat: add split tab data model * feat: make split screens top-level tabs * feat: persist and manage split layouts * feat: launch native RDP on Windows desktop (#1223) * feat: launch native RDP on Windows * style: format native RDP launcher * feat: enhance custom disk and network metrics (#1220) * feat: enhance host disk and network metrics * fix: align enhanced metrics types * fix: preserve Proxmox guest identity on edit (#1219) * fix: preserve Proxmox guest identity on edit * fix: type Proxmox guest source metadata * chore: dead-code cleanup and small refactors (#1225) * chore: remove dead code and unused exports * chore: remove unused api client functions * chore: remove unused backend helpers * refactor: extract getErrorMessage helper for repeated error extraction * refactor: unify error message extraction across backend with getErrorMessage * refactor: unify error message extraction in frontend with getErrorMessage * refactor: merge duplicate imports from the same module * refactor: use Array.includes in TabBar * chore: drop biome, keep prettier as the single formatter * style: apply prettier formatting to refactored files * fix: close active tab with Ctrl+W on Windows * fix: make tray Quit terminate the desktop app * feat: verify host transfer integrity * fix: reuse transfer sessions during verification * feat: select the fastest host transfer route * feat: tune host transfers adaptively * feat: adapt background polling to activity (#1233) * feat: adapt background polling to activity * feat: extend adaptive polling coverage * feat: make polling cost and network aware (#1234) * feat: make repeat navigation feel instant (#1235) * feat: make file operations feel immediate (#1236) * feat: preload likely user actions (#1237) * feat: preload likely file previews * feat: preload likely host tools * feat: preload likely file viewers * fix: replace stale terminal input listeners * feat: add links to docs for all new features * chore: update readme * fix: warn before discarding host changes (#1229) * feat: learn local host action preferences (#1238) * feat(terminal-toolbar): add bounded movable desktop toolbar (#1239) * feat: add local adaptive decision engine (#1240) * feat: adapt speculative resource usage (#1241) * feat: persist adaptive transfer profiles (#1242) * Fix .preferred_username when using LDAP login. (#1243) * chore: sync Crowdin translations * Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat: learn direct transfer routes (#1244) * feat: learn speculative preload usefulness (#1245) * fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152) https://github.com/Termix-SSH/Support/issues/1152 * fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145) https://github.com/Termix-SSH/Support/issues/1145 * feat: single click on host in list opens session - should be only on double click (#1146) https://github.com/Termix-SSH/Support/issues/1146 * feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140) https://github.com/Termix-SSH/Support/issues/1140 * fix: revert host single click to open session, make double click an option (#1146) Single click opens a session again by default. The old double click behavior can be turned on in Customize Sidebar. * chore: drop prettier check from beta release workflow, run formatter * chore: patch dependabot vulnerabilities via npm overrides * fix: reset adaptive resource state between tests to stop cross-test leaks * feat: replace terminal toolbar density popover with a native select * fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier * fix: allow dylib files in mac universal arch rules so mas build packages sharp * feat: add file manager trash (#1250) * feat: add inheritable connection defaults (#1246) * feat: add desktop local terminal (#1247) * feat: add interactive terminal macros (#1248) * feat: add adaptive SSH local echo (#1249) * fix: sync desktop host changes immediately (#1252) * fix: route desktop sharing through synced server (#1253) * Fix terminal image uploads and add safe diagnostics (#1254) * feat: add configurable terminal image storage backends * feat: add admin image storage settings * fix: preserve native clipboard PNG uploads * fix: quote terminal image paths safely * docs: record image storage security remediation plan * fix: close remote image SFTP channels * fix: restrict remote image SFTP permissions * fix: bound remote image SFTP writes * fix: add best effort remote image retention * fix: cap normalized image output size * fix: bound concurrent image processing * fix: fail closed on local image inspection errors * test: cover fail closed image storage and atomic settings * fix: enforce remote image quota and upload admission * fix: serialize remote quota and verify existing paths * fix: use synchronous sqlite settings transaction * fix: keep settings transactions portable across dialects * fix: bound image processing admission queue * fix: serialize remote image quota across processes * fix: recover stale remote image locks safely * fix: preserve remote storage errors during unlock * fix: fail closed when stale lock removal fails * fix: harden image upload resource and storage cleanup * fix: bound SFTP operations and lock lifetime * fix: bound SFTP acquisition and cleanup callbacks * fix: close late SFTP channels and test cleanup stalls * fix: preserve SFTP inspection client context * feat: add image upload source metadata * fix: expose image upload metadata in logs * chore: exclude internal plan from pull request * style: apply prettier formatting --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: batch of security hardening fixes (#1255) * fix: bind desktop auto-session loopback check to the TCP peer address * fix: escape HTML entities in Vault OIDC callback responses * fix: route homepage ping and rss through the SSRF-safe outbound fetch * fix: scope tunnel status endpoints to hosts the caller can access * chore: update release notes * chore: update release notes to write more about the ai integration * fix: unbreak windows and macos electron builds after node-pty Install Spectre-mitigated MSVC libs on the Windows runner and cover node-pty's spawn-helper in the macOS universal arch rules. * fix: rework connection defaults ui into a dialog and add missing i18n keys * fix: rework macros panel with i18n, plain text matching, and list layout * feat: add docs links for trash, connection defaults, and local echo * fix: make image storage and trash tests pass on windows * fix: stop docs links squeezing sidebar panel headers * fix: put automations docs link back on the tabs row * fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261) Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls back to the basic_text store for any desktop it has no mapping for, which covers every wlroots-style compositor (Hyprland, sway, niri, river). isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt refused every write and the OIDC sign-in it was storing appeared to succeed. The sync engine then found no JWT and reported the session as expired, which sent users looking at their OIDC provider for a fault that was never there. Name the libsecret backend explicitly on those desktops. They run an ordinary Secret Service, so that is enough to make encryption available again. KWallet desktops keep their auto-detected backend, an explicit --password-store still wins, and no stored secret can be orphaned by the switch because isEncryptionAvailable() gated every write that would have created one. Also stop discarding the {success: false} the main process returns when it cannot store a credential: on a machine with no Secret Service at all, the sign-in now says so instead of silently completing. Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com> * chore: update release notes * chore: update release notes * fix(file-manager): widen trash dialog so names and paths are not cut off * fix(sidebar): stop hover action tray overlapping the row below it * fix(hosts): make real status colors toggle actually apply * feat(local-terminal): add rail button and fix hardcoded tab label * chore: update release notes * fix(ai): hide assistant everywhere when admin disables it globally * fix(automations): fix concurrency race, wire docker and internal event triggers Claim the in-flight slot in the same tick it is checked, poll container state for docker_event triggers, emit the internal events, apply the schedule time zone, and expose the concurrency policy in the editor. * fix(sidebar): rework host and credential drag-to-reorder Adds a lock toggle in the sort menu and fixes reorder positioning, cross-folder drops, and the duplicate drop indicator. * chore(sidebar): drop unused sortKey prop from host and credential trees * fix(sidebar): fix row height in click tray mode so status stripes stop overlapping * fix(onboarding): remove add-first-host step that closed onboarding mid-flow * fix(release): upload release notes so Mac App Store review submission stops failing * chore: sync Crowdin translations for 2.7.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com> Co-authored-by: Horziox <horziox.dev@gmail.com> Co-authored-by: William Shi <184219650@qq.com> Co-authored-by: Carl <scarlettme@qq.com> Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com> Co-authored-by: njz-cvm <njz@cvm.com> Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com> Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
co-authored by
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
kacperpietrzyk
Brennan Neoh
Claude Sonnet 5
T3rM1nAt0-R
Horziox
William Shi
Carl
Raw_Nuke
njz-cvm
Alexandre VARGAS
alexandre-vl
parent
5021ccf3e2
commit
7ae1648c25
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The system prompt.
|
||||
*
|
||||
* Deliberately small: the assistant starts with almost no context and must call
|
||||
* a read tool to learn anything. That keeps token cost predictable, makes every
|
||||
* data access visible in the transcript, and means nothing is sent to a
|
||||
* third-party provider that the user did not implicitly ask for.
|
||||
*/
|
||||
export function buildSystemPrompt(options: {
|
||||
hostCount: number;
|
||||
activeTab?: string | null;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}): string {
|
||||
const lines: string[] = [
|
||||
"You are the assistant built into Termix, a self-hosted server management app.",
|
||||
"You help the user manage their servers, snippets, automations, fleets and alerts.",
|
||||
"",
|
||||
"How you work:",
|
||||
"- You start with no knowledge of this user's setup. Call a read tool to find out anything you need.",
|
||||
"- You cannot change anything directly. To make a change, call a propose_* tool; the user sees a card and approves or rejects it.",
|
||||
"- You have no access to passwords, SSH keys, API keys or any other credential, and you never ask the user to paste one into the chat.",
|
||||
"",
|
||||
"Scope:",
|
||||
"- Do exactly what was asked, and nothing beyond it. A question is a request for an answer, not for changes.",
|
||||
"- Questions like 'what is running on this server', 'check this host' or 'why is this slow' are answered with information. Read, then report. Do not propose anything.",
|
||||
"- Only propose a change when the user asked for one, in words like 'add', 'create', 'set up', 'fix' or 'change'.",
|
||||
"- Do not propose follow-up work you thought of yourself: no monitoring, no alert rules, no scripts, no snippets, no cleanup, unless that is what was requested.",
|
||||
"- If you think something is worth doing, say so in one sentence and stop. Let the user ask.",
|
||||
"- One request means one proposal at most. Do not bundle extras alongside it.",
|
||||
"",
|
||||
"How to answer:",
|
||||
"- Lead with the answer. Keep responses short and concrete.",
|
||||
"- When you propose something, say in one line what it does and why.",
|
||||
"- If a request is ambiguous in a way that changes what you would propose, ask before proposing.",
|
||||
"- Never claim you have done something. You propose; the user applies.",
|
||||
];
|
||||
|
||||
if (options.allowReadOnlyCommands) {
|
||||
lines.push(
|
||||
"- The user has allowed you to run read-only diagnostic commands directly. Anything that changes state still has to be proposed.",
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Current context:",
|
||||
`- The user has ${options.hostCount} host${options.hostCount === 1 ? "" : "s"} configured.`,
|
||||
);
|
||||
|
||||
if (options.activeTab) {
|
||||
lines.push(`- They are currently looking at: ${options.activeTab}.`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { isIP } from "net";
|
||||
import { isBlockedAddress } from "../utils/safe-outbound-fetch.js";
|
||||
import { createCurrentSettingsRepository } from "../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Where the assistant is allowed to send requests.
|
||||
*
|
||||
* Cloud providers are reached through the SSRF-guarded path, which refuses to
|
||||
* resolve to a private address. That guard is exactly what a self-hosted Ollama
|
||||
* on localhost trips over, so private destinations are permitted only when an
|
||||
* admin has named the host. Without that split, any logged-in user could point
|
||||
* a "provider" at an internal service and use the backend as an authenticated
|
||||
* probe of the server's own network.
|
||||
*/
|
||||
|
||||
export const AI_PRIVATE_ALLOWLIST_KEY = "ai_private_endpoint_allowlist";
|
||||
|
||||
/** Hosts a self-hoster almost certainly wants, and which reach only this machine. */
|
||||
export const DEFAULT_PRIVATE_ALLOWLIST = [
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"host.docker.internal",
|
||||
];
|
||||
|
||||
export function parseAllowlist(raw: string | null): string[] {
|
||||
if (!raw) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
return parsed
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [...DEFAULT_PRIVATE_ALLOWLIST];
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPrivateAllowlist(): Promise<string[]> {
|
||||
const raw = await createCurrentSettingsRepository().get(
|
||||
AI_PRIVATE_ALLOWLIST_KEY,
|
||||
);
|
||||
return parseAllowlist(raw);
|
||||
}
|
||||
|
||||
function normalizeHost(hostname: string): string {
|
||||
return hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the URL names a destination the SSRF guard would refuse. A bare
|
||||
* hostname that is not an IP literal (e.g. "ollama.internal") is treated as
|
||||
* private only if it is "localhost" -- anything else resolves through DNS and
|
||||
* is caught at connect time by the guard instead.
|
||||
*/
|
||||
export function isPrivateDestination(rawUrl: string): boolean {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = normalizeHost(url.hostname);
|
||||
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
||||
if (isIP(host)) return isBlockedAddress(host);
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface EgressDecision {
|
||||
allowed: boolean;
|
||||
/** True when the destination needs the allowlisted-private path. */
|
||||
isPrivate: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function evaluateEgress(
|
||||
rawUrl: string,
|
||||
allowlist: string[],
|
||||
): EgressDecision {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, reason: "Invalid URL" };
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
return { allowed: false, isPrivate: false, reason: "Unsupported protocol" };
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: false,
|
||||
reason: "Credentials in URL are not allowed",
|
||||
};
|
||||
}
|
||||
|
||||
const host = normalizeHost(url.hostname);
|
||||
const isPrivate = isPrivateDestination(rawUrl);
|
||||
|
||||
if (!isPrivate) return { allowed: true, isPrivate: false };
|
||||
|
||||
const normalized = allowlist.map((entry) => entry.trim().toLowerCase());
|
||||
if (normalized.includes(host)) return { allowed: true, isPrivate: true };
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
reason:
|
||||
"This address is on a private network. An administrator must add its host to the AI endpoint allowlist first.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { getAdapter } from "./providers/registry.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
ProviderConfig,
|
||||
ToolCall,
|
||||
} from "./providers/types.js";
|
||||
import { redact, redactToJson } from "./redaction.js";
|
||||
import { getTool, toolDefinitions } from "./tools/catalog.js";
|
||||
import {
|
||||
isProposalDraft,
|
||||
type ProposalDraft,
|
||||
type ToolContext,
|
||||
} from "./tools/types.js";
|
||||
|
||||
/**
|
||||
* The agent loop: stream a turn, run any tools the model asked for, feed the
|
||||
* results back, repeat. Bounded so a model that keeps calling tools cannot spin
|
||||
* forever.
|
||||
*/
|
||||
|
||||
const MAX_TURNS = 8;
|
||||
|
||||
export type EngineEvent =
|
||||
| { type: "token"; text: string }
|
||||
| { type: "tool_call"; name: string; arguments: Record<string, unknown> }
|
||||
| { type: "tool_result"; name: string; result: unknown }
|
||||
| { type: "proposal"; draft: ProposalDraft }
|
||||
| { type: "assistant_message"; content: string; toolCalls: ToolCall[] }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export interface EngineOptions {
|
||||
config: ProviderConfig;
|
||||
model: string;
|
||||
system: string;
|
||||
history: ChatMessage[];
|
||||
context: ToolContext;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function* runAgent(
|
||||
options: EngineOptions,
|
||||
): AsyncGenerator<EngineEvent> {
|
||||
const adapter = getAdapter(options.config.providerType);
|
||||
const tools = toolDefinitions();
|
||||
const messages: ChatMessage[] = [...options.history];
|
||||
|
||||
for (let turn = 0; turn < MAX_TURNS; turn += 1) {
|
||||
let text = "";
|
||||
const calls: ToolCall[] = [];
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
for await (const chunk of adapter.streamChat(options.config, {
|
||||
model: options.model,
|
||||
system: options.system,
|
||||
messages,
|
||||
tools,
|
||||
signal: options.signal,
|
||||
})) {
|
||||
if (chunk.type === "text") {
|
||||
text += chunk.text;
|
||||
yield { type: "token", text: chunk.text };
|
||||
} else if (chunk.type === "tool_call") {
|
||||
calls.push(chunk.call);
|
||||
} else if (chunk.type === "error") {
|
||||
failed = true;
|
||||
yield { type: "error", message: chunk.message };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, "The provider request failed");
|
||||
yield { type: "error", message };
|
||||
return;
|
||||
}
|
||||
|
||||
if (failed) return;
|
||||
|
||||
yield { type: "assistant_message", content: text, toolCalls: calls };
|
||||
|
||||
if (!calls.length) {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
}
|
||||
|
||||
messages.push({ role: "assistant", content: text, toolCalls: calls });
|
||||
|
||||
for (const call of calls) {
|
||||
yield { type: "tool_call", name: call.name, arguments: call.arguments };
|
||||
|
||||
const result = await runTool(call, options.context);
|
||||
|
||||
if (isProposalDraft(result)) {
|
||||
// Closes the tool call before the proposal card is emitted. Without
|
||||
// this the call has no matching result and renders as permanently
|
||||
// running, even though the work is done and awaiting the user.
|
||||
yield {
|
||||
type: "tool_result",
|
||||
name: call.name,
|
||||
result: { status: "awaiting_user_approval" },
|
||||
};
|
||||
yield { type: "proposal", draft: result };
|
||||
// The model is told the proposal is awaiting the user rather than done,
|
||||
// so it does not go on to describe the change as applied.
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
status: "awaiting_user_approval",
|
||||
summary: result.summary,
|
||||
}),
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
yield { type: "tool_result", name: call.name, result: redact(result) };
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: redactToJson(result),
|
||||
toolCallId: call.id,
|
||||
toolName: call.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ran out of turns with the model still calling tools.
|
||||
yield {
|
||||
type: "error",
|
||||
message: "The assistant used too many steps without finishing.",
|
||||
};
|
||||
}
|
||||
|
||||
async function runTool(call: ToolCall, context: ToolContext): Promise<unknown> {
|
||||
const tool = getTool(call.name);
|
||||
|
||||
// A model can emit any name it likes; only the catalog decides what runs.
|
||||
if (!tool) {
|
||||
return { error: `Unknown tool: ${call.name}` };
|
||||
}
|
||||
|
||||
try {
|
||||
return await tool.handler(call.arguments ?? {}, context);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: getErrorMessage(error, "The tool failed"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { NextFunction, Response } from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
|
||||
/**
|
||||
* Three gates, all checked on the server.
|
||||
*
|
||||
* The admin global is a hard kill switch: when it is off the feature does not
|
||||
* exist for anyone, regardless of what any user has enabled. It defaults to
|
||||
* false so upgrading an existing install turns nothing on by surprise.
|
||||
*
|
||||
* Mirrors the shape of isSharingEnabledForHost in the session-sharing routes,
|
||||
* where the global also wins over the per-entity setting.
|
||||
*/
|
||||
|
||||
export const AI_GLOBAL_ENABLED_KEY = "ai_globally_enabled";
|
||||
|
||||
export async function isAiGloballyEnabled(): Promise<boolean> {
|
||||
return createCurrentSettingsRepository().getBoolean(
|
||||
AI_GLOBAL_ENABLED_KEY,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
export interface AiAccess {
|
||||
enabled: boolean;
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export async function resolveAiAccess(userId: string): Promise<AiAccess> {
|
||||
const globalEnabled = await isAiGloballyEnabled();
|
||||
if (!globalEnabled) {
|
||||
return { enabled: false, allowReadOnlyCommands: false };
|
||||
}
|
||||
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
|
||||
return {
|
||||
// Null means the user was never asked, which is not consent.
|
||||
enabled: preferences?.aiAssistantEnabled === true,
|
||||
allowReadOnlyCommands: preferences?.aiReadOnlyCommands === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rejects any AI request unless both gates are open. */
|
||||
export function createAiGate() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
if (!req.userId) {
|
||||
res.status(401).json({ error: "Authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const access = await resolveAiAccess(req.userId);
|
||||
if (!access.enabled) {
|
||||
res.status(403).json({ error: "The AI assistant is not enabled" });
|
||||
return;
|
||||
}
|
||||
|
||||
(req as AuthenticatedRequest & { aiAccess?: AiAccess }).aiAccess = access;
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,976 @@
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import express from "express";
|
||||
import type { AuthenticatedRequest } from "../../types/index.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import {
|
||||
getAuditUsername,
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
} from "../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentAiRepository,
|
||||
createCurrentHostRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { buildSystemPrompt } from "./context.js";
|
||||
import { runAgent } from "./engine.js";
|
||||
import {
|
||||
createAiGate,
|
||||
isAiGloballyEnabled,
|
||||
resolveAiAccess,
|
||||
} from "./gating.js";
|
||||
import {
|
||||
FALLBACK_MODELS,
|
||||
getAdapter,
|
||||
REQUIRES_API_KEY,
|
||||
REQUIRES_BASE_URL,
|
||||
} from "./providers/registry.js";
|
||||
import type {
|
||||
AiProviderType,
|
||||
ChatMessage,
|
||||
ProviderConfig,
|
||||
} from "./providers/types.js";
|
||||
import { isAiProviderType } from "./providers/types.js";
|
||||
import { applyProposal } from "./tools/executor.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
const aiGate = createAiGate();
|
||||
|
||||
function parseId(raw: unknown): number | null {
|
||||
const id = typeof raw === "string" ? parseInt(raw, 10) : Number(raw);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/status:
|
||||
* get:
|
||||
* summary: Whether the AI assistant is available to this user
|
||||
* description: >
|
||||
* Deliberately not behind the AI gate: the frontend calls this to decide
|
||||
* whether to render any AI surface at all, and needs a plain answer rather
|
||||
* than a 403 when the feature is off.
|
||||
* tags:
|
||||
* - AI
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The effective enablement state.
|
||||
*/
|
||||
router.get("/status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
try {
|
||||
const [globalEnabled, access] = await Promise.all([
|
||||
isAiGloballyEnabled(),
|
||||
resolveAiAccess(userId),
|
||||
]);
|
||||
res.json({
|
||||
globallyEnabled: globalEnabled,
|
||||
enabled: access.enabled,
|
||||
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
||||
});
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to read AI status", err, {
|
||||
operation: "ai_status_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read AI status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/providers:
|
||||
* get:
|
||||
* summary: List the user's configured AI providers
|
||||
* tags:
|
||||
* - AI
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Providers, with API keys masked.
|
||||
* 403:
|
||||
* description: The AI assistant is not enabled.
|
||||
*/
|
||||
router.get(
|
||||
"/providers",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
try {
|
||||
const providers = await createCurrentAiRepository().listProviders(userId);
|
||||
res.json({ providers });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list AI providers", err, {
|
||||
operation: "ai_providers_list_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list providers" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/providers:
|
||||
* post:
|
||||
* summary: Add an AI provider
|
||||
* tags:
|
||||
* - AI
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerType:
|
||||
* type: string
|
||||
* label:
|
||||
* type: string
|
||||
* baseUrl:
|
||||
* type: string
|
||||
* apiKey:
|
||||
* type: string
|
||||
* defaultModel:
|
||||
* type: string
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Provider created.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post(
|
||||
"/providers",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { providerType, label, baseUrl, apiKey, defaultModel } =
|
||||
req.body ?? {};
|
||||
|
||||
if (!isAiProviderType(providerType)) {
|
||||
return res.status(400).json({ error: "Unknown provider type" });
|
||||
}
|
||||
if (typeof label !== "string" || !label.trim()) {
|
||||
return res.status(400).json({ error: "label is required" });
|
||||
}
|
||||
if (REQUIRES_BASE_URL.includes(providerType) && !baseUrl?.trim()) {
|
||||
return res.status(400).json({ error: "This provider needs a base URL" });
|
||||
}
|
||||
if (REQUIRES_API_KEY.includes(providerType) && !apiKey?.trim()) {
|
||||
return res.status(400).json({ error: "This provider needs an API key" });
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createCurrentAiRepository().createProvider({
|
||||
userId,
|
||||
providerType,
|
||||
label: label.trim(),
|
||||
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
|
||||
apiKey: typeof apiKey === "string" ? apiKey.trim() : null,
|
||||
defaultModel:
|
||||
typeof defaultModel === "string" ? defaultModel.trim() : null,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "create_ai_provider",
|
||||
resourceType: "ai_provider",
|
||||
resourceId: String(created.id),
|
||||
resourceName: created.label,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.status(201).json({ provider: created });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to create AI provider", err, {
|
||||
operation: "ai_provider_create_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create provider" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/providers/{id}:
|
||||
* patch:
|
||||
* summary: Update an AI provider
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider updated.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.patch(
|
||||
"/providers/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
||||
|
||||
try {
|
||||
const updated = await createCurrentAiRepository().updateProvider(
|
||||
id,
|
||||
userId,
|
||||
req.body ?? {},
|
||||
);
|
||||
if (!updated)
|
||||
return res.status(404).json({ error: "Provider not found" });
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "update_ai_provider",
|
||||
resourceType: "ai_provider",
|
||||
resourceId: String(id),
|
||||
resourceName: updated.label,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ provider: updated });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to update AI provider", err, {
|
||||
operation: "ai_provider_update_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update provider" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete an AI provider
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider deleted.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.delete(
|
||||
"/providers/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentAiRepository().deleteProvider(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!deleted)
|
||||
return res.status(404).json({ error: "Provider not found" });
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "delete_ai_provider",
|
||||
resourceType: "ai_provider",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to delete AI provider", err, {
|
||||
operation: "ai_provider_delete_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete provider" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/probe-models:
|
||||
* post:
|
||||
* summary: List models for a provider that has not been saved yet
|
||||
* description: >
|
||||
* Lets the add-provider form fill its model picker before the provider
|
||||
* exists, so nobody has to go and look up model names by hand.
|
||||
* tags:
|
||||
* - AI
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerType:
|
||||
* type: string
|
||||
* baseUrl:
|
||||
* type: string
|
||||
* apiKey:
|
||||
* type: string
|
||||
* providerId:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Model ids, possibly a curated fallback list.
|
||||
* 400:
|
||||
* description: Unknown provider type.
|
||||
*/
|
||||
router.post(
|
||||
"/probe-models",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { providerType, baseUrl, apiKey, providerId } = req.body ?? {};
|
||||
|
||||
if (!isAiProviderType(providerType)) {
|
||||
return res.status(400).json({ error: "Unknown provider type" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Editing an existing provider sends no key, so fall back to the stored
|
||||
// one rather than making the user retype it just to refresh the list.
|
||||
let resolvedKey =
|
||||
typeof apiKey === "string" && apiKey.trim() ? apiKey.trim() : null;
|
||||
if (!resolvedKey && parseId(providerId)) {
|
||||
const stored = await createCurrentAiRepository().findProviderWithSecret(
|
||||
parseId(providerId) as number,
|
||||
userId,
|
||||
);
|
||||
resolvedKey = stored?.apiKey ?? null;
|
||||
}
|
||||
|
||||
const models = await getAdapter(providerType).listModels({
|
||||
providerType,
|
||||
baseUrl: typeof baseUrl === "string" ? baseUrl.trim() : null,
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
|
||||
res.json({ models, source: "live" });
|
||||
} catch (err) {
|
||||
// A provider that cannot be reached yet still gets a usable list, so the
|
||||
// form is never a blank text box the user has to guess into.
|
||||
const fallback = FALLBACK_MODELS[providerType as AiProviderType] ?? [];
|
||||
res.json({
|
||||
models: fallback,
|
||||
source: fallback.length ? "fallback" : "none",
|
||||
warning: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/providers/{id}/models:
|
||||
* get:
|
||||
* summary: List models available from a provider
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Model ids.
|
||||
* 502:
|
||||
* description: The provider could not be reached.
|
||||
*/
|
||||
router.get(
|
||||
"/providers/:id/models",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid provider id" });
|
||||
|
||||
try {
|
||||
const provider = await createCurrentAiRepository().findProviderWithSecret(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!provider)
|
||||
return res.status(404).json({ error: "Provider not found" });
|
||||
|
||||
const models = await getAdapter(provider.providerType).listModels({
|
||||
providerType: provider.providerType as ProviderConfig["providerType"],
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
});
|
||||
res.json({ models });
|
||||
} catch (err) {
|
||||
// The message can carry the allowlist hint, which the user needs to act on.
|
||||
const message = getErrorMessage(err, "Could not reach the provider");
|
||||
databaseLogger.warn("Failed to list provider models", {
|
||||
operation: "ai_provider_models_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(502).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/conversations:
|
||||
* get:
|
||||
* summary: List the user's AI conversations
|
||||
* tags:
|
||||
* - AI
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Conversations, newest first.
|
||||
*/
|
||||
router.get(
|
||||
"/conversations",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
try {
|
||||
const conversations =
|
||||
await createCurrentAiRepository().listConversations(userId);
|
||||
res.json({ conversations });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to list AI conversations", err, {
|
||||
operation: "ai_conversations_list_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list conversations" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/conversations/{id}:
|
||||
* get:
|
||||
* summary: Get one conversation with its messages and proposals
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The conversation.
|
||||
* 404:
|
||||
* description: Conversation not found.
|
||||
*/
|
||||
router.get(
|
||||
"/conversations/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
|
||||
|
||||
try {
|
||||
const repository = createCurrentAiRepository();
|
||||
const conversation = await repository.findConversation(id, userId);
|
||||
if (!conversation) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
|
||||
const [messages, proposals] = await Promise.all([
|
||||
repository.listMessages(id),
|
||||
repository.listProposals(userId, id),
|
||||
]);
|
||||
|
||||
res.json({ conversation, messages, proposals });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to load AI conversation", err, {
|
||||
operation: "ai_conversation_load_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to load conversation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/conversations/{id}:
|
||||
* delete:
|
||||
* summary: Delete a conversation
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Conversation deleted.
|
||||
*/
|
||||
router.delete(
|
||||
"/conversations/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid conversation id" });
|
||||
|
||||
try {
|
||||
const deleted = await createCurrentAiRepository().deleteConversation(
|
||||
id,
|
||||
userId,
|
||||
);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to delete AI conversation", err, {
|
||||
operation: "ai_conversation_delete_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete conversation" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/chat/stream:
|
||||
* post:
|
||||
* summary: Send a message and stream the assistant's reply
|
||||
* description: >
|
||||
* Server-sent events. Emits token, tool_call, tool_result, proposal,
|
||||
* done and error frames.
|
||||
* tags:
|
||||
* - AI
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* conversationId:
|
||||
* type: integer
|
||||
* providerId:
|
||||
* type: integer
|
||||
* model:
|
||||
* type: string
|
||||
* message:
|
||||
* type: string
|
||||
* activeTab:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
* 400:
|
||||
* description: Invalid request body.
|
||||
*/
|
||||
router.post(
|
||||
"/chat/stream",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const { conversationId, providerId, model, message, activeTab } =
|
||||
req.body ?? {};
|
||||
|
||||
if (typeof message !== "string" || !message.trim()) {
|
||||
return res.status(400).json({ error: "message is required" });
|
||||
}
|
||||
|
||||
const resolvedProviderId = parseId(providerId);
|
||||
if (!resolvedProviderId) {
|
||||
return res.status(400).json({ error: "providerId is required" });
|
||||
}
|
||||
|
||||
const repository = createCurrentAiRepository();
|
||||
|
||||
try {
|
||||
const provider = await repository.findProviderWithSecret(
|
||||
resolvedProviderId,
|
||||
userId,
|
||||
);
|
||||
if (!provider) {
|
||||
return res.status(404).json({ error: "Provider not found" });
|
||||
}
|
||||
|
||||
const chosenModel =
|
||||
(typeof model === "string" && model.trim()) ||
|
||||
provider.defaultModel ||
|
||||
"";
|
||||
if (!chosenModel) {
|
||||
return res.status(400).json({ error: "No model selected" });
|
||||
}
|
||||
|
||||
// Resolve or create the conversation before the stream opens, so a
|
||||
// failure here is still a normal JSON error the client can render.
|
||||
let conversation = conversationId
|
||||
? await repository.findConversation(Number(conversationId), userId)
|
||||
: null;
|
||||
if (!conversation) {
|
||||
conversation = await repository.createConversation({
|
||||
userId,
|
||||
title: message.trim().slice(0, 60),
|
||||
providerId: resolvedProviderId,
|
||||
model: chosenModel,
|
||||
});
|
||||
}
|
||||
|
||||
const history = await repository.listMessages(conversation.id);
|
||||
await repository.appendMessage({
|
||||
conversationId: conversation.id,
|
||||
role: "user",
|
||||
content: message.trim(),
|
||||
});
|
||||
|
||||
const access = await resolveAiAccess(userId);
|
||||
const hosts = await createCurrentHostRepository().listByUserId(userId);
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const abort = new AbortController();
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const send = (event: unknown) => {
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
};
|
||||
|
||||
send({ type: "conversation", conversationId: conversation.id });
|
||||
|
||||
const chatHistory: ChatMessage[] = history.map((entry) => ({
|
||||
role: entry.role as ChatMessage["role"],
|
||||
content: entry.content,
|
||||
...(entry.toolCalls ? { toolCalls: JSON.parse(entry.toolCalls) } : {}),
|
||||
}));
|
||||
chatHistory.push({ role: "user", content: message.trim() });
|
||||
|
||||
let assistantText = "";
|
||||
let assistantToolCalls: unknown[] = [];
|
||||
|
||||
try {
|
||||
for await (const event of runAgent({
|
||||
config: {
|
||||
providerType:
|
||||
provider.providerType as ProviderConfig["providerType"],
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
},
|
||||
model: chosenModel,
|
||||
system: buildSystemPrompt({
|
||||
hostCount: hosts.length,
|
||||
activeTab: typeof activeTab === "string" ? activeTab : null,
|
||||
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
||||
}),
|
||||
history: chatHistory,
|
||||
context: {
|
||||
userId,
|
||||
conversationId: conversation.id,
|
||||
allowReadOnlyCommands: access.allowReadOnlyCommands,
|
||||
},
|
||||
signal: abort.signal,
|
||||
})) {
|
||||
if (event.type === "assistant_message") {
|
||||
assistantText = event.content;
|
||||
// Kept so the next message replays them verbatim. Gemini rejects a
|
||||
// turn whose functionCall parts lost their thoughtSignature, so
|
||||
// dropping these breaks the second message in every conversation.
|
||||
assistantToolCalls = event.toolCalls;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === "proposal") {
|
||||
const stored = await repository.createProposal({
|
||||
conversationId: conversation.id,
|
||||
userId,
|
||||
kind: event.draft.kind,
|
||||
summary: event.draft.summary,
|
||||
payload: JSON.stringify(event.draft.payload),
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "ai_proposal_created",
|
||||
resourceType: "ai_proposal",
|
||||
resourceId: String(stored.id),
|
||||
resourceName: event.draft.kind,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
send({ type: "proposal", proposal: stored });
|
||||
continue;
|
||||
}
|
||||
|
||||
send(event);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
|
||||
if (assistantText || assistantToolCalls.length) {
|
||||
await repository.appendMessage({
|
||||
conversationId: conversation.id,
|
||||
role: "assistant",
|
||||
content: assistantText,
|
||||
toolCalls: assistantToolCalls.length
|
||||
? JSON.stringify(assistantToolCalls)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
await repository.touchConversation(conversation.id);
|
||||
|
||||
send({ type: "done" });
|
||||
res.end();
|
||||
} catch (err) {
|
||||
databaseLogger.error("AI chat stream failed", err, {
|
||||
operation: "ai_chat_stream_failed",
|
||||
userId,
|
||||
});
|
||||
if (res.headersSent) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({ type: "error", message: "The assistant stopped unexpectedly" })}\n\n`,
|
||||
);
|
||||
res.end();
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to start the assistant" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/proposals/{id}/apply:
|
||||
* post:
|
||||
* summary: Apply a pending proposal
|
||||
* description: >
|
||||
* Re-validates the stored payload and dispatches it through the same
|
||||
* repository logic a manual action uses.
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Proposal applied.
|
||||
* 400:
|
||||
* description: The proposal could not be applied.
|
||||
* 404:
|
||||
* description: Proposal not found.
|
||||
*/
|
||||
router.post(
|
||||
"/proposals/:id/apply",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
|
||||
|
||||
const repository = createCurrentAiRepository();
|
||||
|
||||
try {
|
||||
const stored = await repository.findProposal(id, userId);
|
||||
if (!stored) return res.status(404).json({ error: "Proposal not found" });
|
||||
if (stored.status !== "pending") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `This proposal was already ${stored.status}` });
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(stored.payload) as Record<string, unknown>;
|
||||
} catch {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "The proposal payload is invalid" });
|
||||
}
|
||||
|
||||
const result = await applyProposal(stored.kind, payload, userId);
|
||||
await repository.setProposalStatus(id, userId, "applied", result.summary);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "ai_proposal_applied",
|
||||
resourceType: "ai_proposal",
|
||||
resourceId: String(id),
|
||||
resourceName: stored.kind,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: result.ok, summary: result.summary });
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, "Failed to apply the proposal");
|
||||
databaseLogger.error("Failed to apply AI proposal", err, {
|
||||
operation: "ai_proposal_apply_failed",
|
||||
userId,
|
||||
});
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "ai_proposal_applied",
|
||||
resourceType: "ai_proposal",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: false,
|
||||
errorMessage: message,
|
||||
});
|
||||
|
||||
res.status(400).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /ai/proposals/{id}/reject:
|
||||
* post:
|
||||
* summary: Reject a pending proposal
|
||||
* tags:
|
||||
* - AI
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Proposal rejected.
|
||||
* 404:
|
||||
* description: Proposal not found.
|
||||
*/
|
||||
router.post(
|
||||
"/proposals/:id/reject",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
aiGate,
|
||||
async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId as string;
|
||||
const id = parseId(req.params.id);
|
||||
if (!id) return res.status(400).json({ error: "Invalid proposal id" });
|
||||
|
||||
try {
|
||||
const updated = await createCurrentAiRepository().setProposalStatus(
|
||||
id,
|
||||
userId,
|
||||
"rejected",
|
||||
);
|
||||
if (!updated) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Proposal not found or already resolved" });
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: await getAuditUsername(userId),
|
||||
action: "ai_proposal_rejected",
|
||||
resourceType: "ai_proposal",
|
||||
resourceId: String(id),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to reject AI proposal", err, {
|
||||
operation: "ai_proposal_reject_failed",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to reject the proposal" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,162 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { providerFetch } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Model ids offered in the picker. Users can type any other id; this is a
|
||||
* convenience list, not a restriction.
|
||||
*/
|
||||
export const ANTHROPIC_MODELS = [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
|
||||
function createClient(config: ProviderConfig): Anthropic {
|
||||
if (!config.apiKey) {
|
||||
throw new AiProviderError("This provider needs an API key");
|
||||
}
|
||||
return new Anthropic({
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
||||
// Routes the SDK's HTTP through the shared egress guard.
|
||||
fetch: providerFetch as unknown as typeof fetch,
|
||||
});
|
||||
}
|
||||
|
||||
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: message.toolCallId ?? "",
|
||||
content: message.content,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
const content: Anthropic.ContentBlockParam[] = [];
|
||||
if (message.content)
|
||||
content.push({ type: "text", text: message.content });
|
||||
for (const call of message.toolCalls) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input: call.arguments,
|
||||
});
|
||||
}
|
||||
messages.push({ role: "assistant", content });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "system") continue;
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK throws its own typed errors rather than going through assertOk, so
|
||||
* they are translated here to match what every other provider reports.
|
||||
*/
|
||||
function translateSdkError(error: unknown): never {
|
||||
const status =
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? (error as { status: number }).status
|
||||
: undefined;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (status === 429) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
throw new AiProviderError(
|
||||
status ? `Anthropic request failed (${status}): ${detail}` : detail,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
export const anthropicAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const client = createClient(config);
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model: request.model,
|
||||
max_tokens: 16000,
|
||||
system: request.system,
|
||||
messages: toAnthropicMessages(request),
|
||||
thinking: { type: "adaptive" },
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(request.signal ? { signal: request.signal } : {}),
|
||||
});
|
||||
|
||||
let final: Anthropic.Message;
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
event.delta.type === "text_delta"
|
||||
) {
|
||||
yield { type: "text", text: event.delta.text };
|
||||
}
|
||||
}
|
||||
final = await stream.finalMessage();
|
||||
} catch (error) {
|
||||
translateSdkError(error);
|
||||
}
|
||||
|
||||
for (const block of final.content) {
|
||||
if (block.type === "tool_use") {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: (block.input ?? {}) as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason: final.stop_reason ?? undefined };
|
||||
},
|
||||
|
||||
async listModels(): Promise<string[]> {
|
||||
return [...ANTHROPIC_MODELS];
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { assertOk, providerFetch, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
const GEMINI_DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
return config.baseUrl?.trim() || GEMINI_DEFAULT_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini has no tool role: a tool result is a user-side functionResponse part,
|
||||
* and an assistant tool request is a model-side functionCall part.
|
||||
*/
|
||||
function toGeminiContents(request: ChatRequest): unknown[] {
|
||||
const contents: unknown[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: message.toolName ?? "tool",
|
||||
response: { result: message.content },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
const parts: unknown[] = [];
|
||||
if (message.content) parts.push({ text: message.content });
|
||||
for (const call of message.toolCalls) {
|
||||
// thoughtSignature must be returned exactly as received or Gemini
|
||||
// 2.5+ rejects the turn with a 400.
|
||||
parts.push({
|
||||
functionCall: { name: call.name, args: call.arguments },
|
||||
...(call.providerSignature
|
||||
? { thoughtSignature: call.providerSignature }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
contents.push({ role: "model", parts });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "system") continue;
|
||||
|
||||
contents.push({
|
||||
role: message.role === "assistant" ? "model" : "user",
|
||||
parts: [{ text: message.content }],
|
||||
});
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini rejects the JSON Schema keywords it does not implement, so the tool
|
||||
* schemas are trimmed to the subset it accepts.
|
||||
*/
|
||||
function toGeminiSchema(schema: unknown): unknown {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (Array.isArray(schema)) return schema.map(toGeminiSchema);
|
||||
|
||||
const source = schema as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key === "additionalProperties" || key === "$schema") continue;
|
||||
if (key === "properties" && value && typeof value === "object") {
|
||||
const properties: Record<string, unknown> = {};
|
||||
for (const [name, child] of Object.entries(
|
||||
value as Record<string, unknown>,
|
||||
)) {
|
||||
properties[name] = toGeminiSchema(child);
|
||||
}
|
||||
output[key] = properties;
|
||||
continue;
|
||||
}
|
||||
output[key] = toGeminiSchema(value);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export const geminiAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
if (!config.apiKey) {
|
||||
throw new AiProviderError("This provider needs an API key");
|
||||
}
|
||||
|
||||
const url = `${baseFor(config).replace(/\/+$/, "")}/models/${encodeURIComponent(request.model)}:streamGenerateContent?alt=sse`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": config.apiKey,
|
||||
},
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: request.system }] },
|
||||
contents: toGeminiContents(request),
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: toGeminiSchema(tool.parameters),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "Gemini");
|
||||
|
||||
let index = 0;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
for await (const data of readSseLines(response)) {
|
||||
let frame: any;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate = frame.candidates?.[0];
|
||||
if (!candidate) continue;
|
||||
if (candidate.finishReason) stopReason = candidate.finishReason;
|
||||
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (typeof part.text === "string" && part.text) {
|
||||
yield { type: "text", text: part.text };
|
||||
}
|
||||
if (part.functionCall?.name) {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: {
|
||||
id: `call_${part.functionCall.name}_${index++}`,
|
||||
name: part.functionCall.name,
|
||||
arguments: (part.functionCall.args ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
// Carried so the next turn can echo it back; without it Gemini
|
||||
// 400s as soon as a tool has been used once.
|
||||
providerSignature: part.thoughtSignature,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
if (!config.apiKey) return [];
|
||||
|
||||
const response = await providerFetch(
|
||||
`${baseFor(config).replace(/\/+$/, "")}/models`,
|
||||
{ method: "GET", headers: { "x-goog-api-key": config.apiKey } },
|
||||
);
|
||||
await assertOk(response, "Gemini");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.models ?? [])
|
||||
.map((entry: any) => String(entry.name ?? "").replace(/^models\//, ""))
|
||||
.filter((name: string) => name.length > 0)
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { getFetchDispatcher } from "../../utils/proxy-agent.js";
|
||||
import { safeOutboundFetch } from "../../utils/safe-outbound-fetch.js";
|
||||
import { evaluateEgress, readPrivateAllowlist } from "../egress.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Every outbound provider request goes through here so the egress rules cannot
|
||||
* be bypassed by an adapter calling fetch directly.
|
||||
*
|
||||
* Public hosts use safeOutboundFetch, which re-checks the resolved address at
|
||||
* connect time. Allowlisted private hosts cannot use it (its whole job is to
|
||||
* refuse them), so they fall back to plain fetch with the proxy dispatcher --
|
||||
* still respecting corporate proxy configuration.
|
||||
*/
|
||||
export async function providerFetch(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const allowlist = await readPrivateAllowlist();
|
||||
const decision = evaluateEgress(url, allowlist);
|
||||
|
||||
if (!decision.allowed) {
|
||||
throw new AiProviderError(decision.reason ?? "Destination not allowed");
|
||||
}
|
||||
|
||||
if (decision.isPrivate) {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
dispatcher: getFetchDispatcher(url),
|
||||
} as RequestInit);
|
||||
}
|
||||
|
||||
return safeOutboundFetch(url, init) as unknown as Promise<Response>;
|
||||
}
|
||||
|
||||
export function joinUrl(base: string, path: string): string {
|
||||
return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields the data payload of each SSE frame. Providers differ in what they put
|
||||
* inside, so parsing the JSON is left to the caller.
|
||||
*/
|
||||
export async function* readSseLines(
|
||||
response: Response,
|
||||
): AsyncGenerator<string> {
|
||||
const body = response.body;
|
||||
if (!body) return;
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line.startsWith("data:")) {
|
||||
yield line.slice(5).trim();
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/** Yields one parsed JSON object per line, for newline-delimited streams. */
|
||||
export async function* readJsonLines(
|
||||
response: Response,
|
||||
): AsyncGenerator<unknown> {
|
||||
const body = response.body;
|
||||
if (!body) return;
|
||||
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line) {
|
||||
try {
|
||||
yield JSON.parse(line);
|
||||
} catch {
|
||||
// A partial or malformed frame is skipped rather than failing the
|
||||
// whole stream.
|
||||
}
|
||||
}
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Digs the human-readable message out of an error body.
|
||||
*
|
||||
* Every provider nests it differently, and dumping the raw JSON produced
|
||||
* something that got cut off mid-sentence. Falls back to a trimmed snippet
|
||||
* when the shape is unfamiliar.
|
||||
*/
|
||||
function extractProviderMessage(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
const message =
|
||||
parsed?.error?.message ??
|
||||
parsed?.error?.["message"] ??
|
||||
parsed?.message ??
|
||||
(typeof parsed?.error === "string" ? parsed.error : null);
|
||||
if (typeof message === "string" && message.trim()) {
|
||||
return message.trim();
|
||||
}
|
||||
} catch {
|
||||
// Not JSON; fall through to the snippet.
|
||||
}
|
||||
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return "";
|
||||
return trimmed.length > 300 ? `${trimmed.slice(0, 300)}...` : trimmed;
|
||||
}
|
||||
|
||||
export async function assertOk(
|
||||
response: Response,
|
||||
provider: string,
|
||||
): Promise<void> {
|
||||
if (response.ok) return;
|
||||
|
||||
let body = "";
|
||||
try {
|
||||
body = await response.text();
|
||||
} catch {
|
||||
body = "";
|
||||
}
|
||||
|
||||
const detail = extractProviderMessage(body);
|
||||
|
||||
// Rate limits and auth failures are the two the user can actually act on,
|
||||
// so they say what to do instead of reading like an internal failure.
|
||||
if (response.status === 429) {
|
||||
throw new AiProviderError(
|
||||
`${provider} rate limit reached. Wait a moment and try again, or check your plan's quota.${
|
||||
detail ? ` (${detail})` : ""
|
||||
}`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new AiProviderError(
|
||||
`${provider} rejected the API key. Check that it is correct and still active.${
|
||||
detail ? ` (${detail})` : ""
|
||||
}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
throw new AiProviderError(
|
||||
`${provider} request failed (${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { assertOk, joinUrl, providerFetch, readJsonLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
|
||||
export const OLLAMA_DEFAULT_BASE = "http://localhost:11434";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
return config.baseUrl?.trim() || OLLAMA_DEFAULT_BASE;
|
||||
}
|
||||
|
||||
function toOllamaMessages(request: ChatRequest): unknown[] {
|
||||
const messages: unknown[] = [{ role: "system", content: request.system }];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: message.content,
|
||||
...(message.toolName ? { tool_name: message.toolName } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message.content,
|
||||
tool_calls: message.toolCalls.map((call) => ({
|
||||
function: { name: call.name, arguments: call.arguments },
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export const ollamaAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/chat"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: toOllamaMessages(request),
|
||||
stream: true,
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "Ollama");
|
||||
|
||||
let index = 0;
|
||||
let stopReason: string | undefined;
|
||||
|
||||
// Ollama streams newline-delimited JSON rather than SSE.
|
||||
for await (const frame of readJsonLines(response)) {
|
||||
const payload = frame as any;
|
||||
|
||||
if (
|
||||
typeof payload.message?.content === "string" &&
|
||||
payload.message.content
|
||||
) {
|
||||
yield { type: "text", text: payload.message.content };
|
||||
}
|
||||
|
||||
for (const call of payload.message?.tool_calls ?? []) {
|
||||
const name = call.function?.name;
|
||||
if (!name) continue;
|
||||
const rawArgs = call.function?.arguments;
|
||||
// Ollama sends an object, but some builds send a JSON string.
|
||||
let args: Record<string, unknown> = {};
|
||||
if (rawArgs && typeof rawArgs === "object") {
|
||||
args = rawArgs as Record<string, unknown>;
|
||||
} else if (typeof rawArgs === "string" && rawArgs.trim()) {
|
||||
try {
|
||||
args = JSON.parse(rawArgs);
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: { id: `call_${name}_${index++}`, name, arguments: args },
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.done) {
|
||||
stopReason = payload.done_reason ?? "stop";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "api/tags"), {
|
||||
method: "GET",
|
||||
});
|
||||
await assertOk(response, "Ollama");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.models ?? [])
|
||||
.map((entry: any) => entry.name)
|
||||
.filter((name: unknown): name is string => typeof name === "string")
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import { assertOk, joinUrl, providerFetch, readSseLines } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
ToolCall,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
const OPENAI_DEFAULT_BASE = "https://api.openai.com/v1";
|
||||
|
||||
function baseFor(config: ProviderConfig): string {
|
||||
if (config.baseUrl?.trim()) return config.baseUrl.trim();
|
||||
if (config.providerType === "openai") return OPENAI_DEFAULT_BASE;
|
||||
throw new AiProviderError("This provider needs a base URL");
|
||||
}
|
||||
|
||||
function toOpenAiMessages(request: ChatRequest): unknown[] {
|
||||
const messages: unknown[] = [{ role: "system", content: request.system }];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: message.toolCallId,
|
||||
content: message.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message.content || null,
|
||||
tool_calls: message.toolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: JSON.stringify(call.arguments),
|
||||
},
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call arguments arrive as JSON fragments spread across many deltas, so
|
||||
* they are accumulated per index and only parsed once the stream ends.
|
||||
*/
|
||||
interface PartialCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export const openAiAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const url = joinUrl(baseFor(config), "chat/completions");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: request.signal,
|
||||
body: JSON.stringify({
|
||||
model: request.model,
|
||||
messages: toOpenAiMessages(request),
|
||||
stream: true,
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
|
||||
await assertOk(response, "OpenAI");
|
||||
|
||||
const partial = new Map<number, PartialCall>();
|
||||
let stopReason: string | undefined;
|
||||
|
||||
for await (const data of readSseLines(response)) {
|
||||
if (data === "[DONE]") break;
|
||||
|
||||
let frame: any;
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const choice = frame.choices?.[0];
|
||||
if (!choice) continue;
|
||||
|
||||
if (choice.finish_reason) stopReason = choice.finish_reason;
|
||||
|
||||
const delta = choice.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
if (typeof delta.content === "string" && delta.content) {
|
||||
yield { type: "text", text: delta.content };
|
||||
}
|
||||
|
||||
for (const call of delta.tool_calls ?? []) {
|
||||
const index = call.index ?? 0;
|
||||
const existing = partial.get(index) ?? { id: "", name: "", args: "" };
|
||||
if (call.id) existing.id = call.id;
|
||||
if (call.function?.name) existing.name = call.function.name;
|
||||
if (call.function?.arguments) existing.args += call.function.arguments;
|
||||
partial.set(index, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of partial.values()) {
|
||||
if (!call.name) continue;
|
||||
yield { type: "tool_call", call: finalizeCall(call) };
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason };
|
||||
},
|
||||
|
||||
async listModels(config: ProviderConfig): Promise<string[]> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
|
||||
const response = await providerFetch(joinUrl(baseFor(config), "models"), {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
await assertOk(response, "OpenAI");
|
||||
|
||||
const body: any = await response.json();
|
||||
return (body.data ?? [])
|
||||
.map((entry: any) => entry.id)
|
||||
.filter((id: unknown): id is string => typeof id === "string")
|
||||
.sort();
|
||||
},
|
||||
};
|
||||
|
||||
export function finalizeCall(call: PartialCall): ToolCall {
|
||||
let args: Record<string, unknown> = {};
|
||||
if (call.args.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(call.args);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
args = parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// A model that emitted malformed arguments gets an empty object; the
|
||||
// tool's own schema validation reports the problem back to it.
|
||||
args = {};
|
||||
}
|
||||
}
|
||||
return {
|
||||
id:
|
||||
call.id || `call_${call.name}_${Math.random().toString(36).slice(2, 10)}`,
|
||||
name: call.name,
|
||||
arguments: args,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { anthropicAdapter } from "./anthropic.js";
|
||||
import { geminiAdapter } from "./gemini.js";
|
||||
import { ollamaAdapter } from "./ollama.js";
|
||||
import { openAiAdapter } from "./openai.js";
|
||||
import {
|
||||
AiProviderError,
|
||||
type AiProviderType,
|
||||
type ProviderAdapter,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* openai_compatible reuses the OpenAI adapter: OpenRouter, Groq, Mistral,
|
||||
* DeepSeek, together.ai, LM Studio and vLLM all speak the same wire format,
|
||||
* they differ only in base URL.
|
||||
*/
|
||||
const ADAPTERS: Record<AiProviderType, ProviderAdapter> = {
|
||||
ollama: ollamaAdapter,
|
||||
anthropic: anthropicAdapter,
|
||||
openai: openAiAdapter,
|
||||
gemini: geminiAdapter,
|
||||
openai_compatible: openAiAdapter,
|
||||
};
|
||||
|
||||
export function getAdapter(providerType: string): ProviderAdapter {
|
||||
const adapter = ADAPTERS[providerType as AiProviderType];
|
||||
if (!adapter) {
|
||||
throw new AiProviderError(`Unknown provider type: ${providerType}`);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/** Provider types that cannot work without a base URL. */
|
||||
export const REQUIRES_BASE_URL: AiProviderType[] = [
|
||||
"ollama",
|
||||
"openai_compatible",
|
||||
];
|
||||
|
||||
/** Provider types that cannot work without an API key. */
|
||||
export const REQUIRES_API_KEY: AiProviderType[] = [
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
];
|
||||
|
||||
/**
|
||||
* Shown when a provider's model list cannot be fetched: no key entered yet,
|
||||
* the endpoint is unreachable, or the vendor has no list endpoint. Users can
|
||||
* always type a model id the list does not contain, so this is a starting
|
||||
* point rather than a restriction.
|
||||
*/
|
||||
export const FALLBACK_MODELS: Record<AiProviderType, string[]> = {
|
||||
ollama: [
|
||||
"llama3.2",
|
||||
"llama3.1",
|
||||
"qwen2.5-coder",
|
||||
"mistral",
|
||||
"phi4",
|
||||
"gemma2",
|
||||
],
|
||||
anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
|
||||
openai: ["gpt-5", "gpt-5-mini", "o4-mini", "gpt-4.1"],
|
||||
gemini: ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"],
|
||||
openai_compatible: [],
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The shape every provider adapter normalizes to. Adding a provider means
|
||||
* translating its wire format into these events; nothing downstream (the
|
||||
* engine, the tool dispatcher, the SSE route) knows which vendor is in use.
|
||||
*/
|
||||
|
||||
export type AiProviderType =
|
||||
"ollama" | "anthropic" | "openai" | "gemini" | "openai_compatible";
|
||||
|
||||
export const AI_PROVIDER_TYPES: AiProviderType[] = [
|
||||
"ollama",
|
||||
"anthropic",
|
||||
"openai",
|
||||
"gemini",
|
||||
"openai_compatible",
|
||||
];
|
||||
|
||||
export function isAiProviderType(value: unknown): value is AiProviderType {
|
||||
return (
|
||||
typeof value === "string" && (AI_PROVIDER_TYPES as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
/** Set on assistant turns that requested tools. */
|
||||
toolCalls?: ToolCall[];
|
||||
/** Set on tool turns, matching the id of the call being answered. */
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
/**
|
||||
* Opaque provider state that has to be echoed back verbatim on the next
|
||||
* turn. Gemini 2.5+ rejects a follow-up whose functionCall parts have lost
|
||||
* their thoughtSignature, so this rides along rather than being dropped.
|
||||
*/
|
||||
providerSignature?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
model: string;
|
||||
system: string;
|
||||
messages: ChatMessage[];
|
||||
tools: ToolDefinition[];
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ChatChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_call"; call: ToolCall }
|
||||
| { type: "done"; stopReason?: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
export interface ProviderConfig {
|
||||
providerType: AiProviderType;
|
||||
baseUrl?: string | null;
|
||||
apiKey?: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderAdapter {
|
||||
/** Streams a single assistant turn. Tool execution happens in the engine. */
|
||||
streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk>;
|
||||
/** Model ids to offer in the picker, best effort. */
|
||||
listModels(config: ProviderConfig): Promise<string[]>;
|
||||
}
|
||||
|
||||
export class AiProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AiProviderError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Defense in depth for anything about to leave the server.
|
||||
*
|
||||
* Read tools already select explicit field allowlists rather than spreading
|
||||
* rows, so nothing secret should reach here. This exists because "should" is
|
||||
* not a guarantee: a future tool that forgets to project its fields would
|
||||
* otherwise ship credentials to a third-party model provider.
|
||||
*/
|
||||
|
||||
const SECRET_KEY_PATTERN =
|
||||
/^(password|passwd|pass|secret|token|api_?key|apikey|private_?key|privatekey|key_?password|keypassword|passphrase|client_?secret|authorization|auth_?token|access_?token|refresh_?token|totp_?secret|backup_?codes|session_?token|cookie|credential|ssh_?cert|data_?key|dek)$/i;
|
||||
|
||||
/** Substring markers for keys that are not exact matches but still sensitive. */
|
||||
const SECRET_KEY_SUBSTRINGS = [
|
||||
"password",
|
||||
"secret",
|
||||
"privatekey",
|
||||
"private_key",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"passphrase",
|
||||
];
|
||||
|
||||
const VALUE_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{
|
||||
pattern:
|
||||
/-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*PRIVATE KEY-----/g,
|
||||
label: "[redacted private key]",
|
||||
},
|
||||
{ pattern: /\bsk-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
|
||||
{ pattern: /\bsk-ant-[A-Za-z0-9_-]{16,}\b/g, label: "[redacted api key]" },
|
||||
{ pattern: /\bghp_[A-Za-z0-9]{20,}\b/g, label: "[redacted token]" },
|
||||
{ pattern: /\btmx_[A-Za-z0-9_-]{16,}\b/g, label: "[redacted token]" },
|
||||
{
|
||||
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\b/g,
|
||||
label: "[redacted token]",
|
||||
},
|
||||
{
|
||||
pattern: /\bBearer\s+[A-Za-z0-9._-]{16,}/gi,
|
||||
label: "Bearer [redacted]",
|
||||
},
|
||||
];
|
||||
|
||||
export const REDACTED = "[redacted]";
|
||||
|
||||
function isSecretKey(key: string): boolean {
|
||||
if (SECRET_KEY_PATTERN.test(key)) return true;
|
||||
const lower = key.toLowerCase();
|
||||
return SECRET_KEY_SUBSTRINGS.some((marker) => lower.includes(marker));
|
||||
}
|
||||
|
||||
export function redactString(value: string): string {
|
||||
let output = value;
|
||||
for (const { pattern, label } of VALUE_PATTERNS) {
|
||||
output = output.replace(pattern, label);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively drops secret-named fields and masks secret-shaped values.
|
||||
* Depth is bounded so a cyclic or pathological structure cannot hang the loop.
|
||||
*/
|
||||
export function redact(value: unknown, depth = 0): unknown {
|
||||
if (depth > 12) return REDACTED;
|
||||
|
||||
if (typeof value === "string") return redactString(value);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => redact(entry, depth + 1));
|
||||
}
|
||||
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (isSecretKey(key)) {
|
||||
// Preserve the shape so the model can still reason about presence,
|
||||
// without ever seeing the value.
|
||||
output[key] = entry === null || entry === undefined ? null : REDACTED;
|
||||
continue;
|
||||
}
|
||||
output[key] = redact(entry, depth + 1);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Convenience wrapper for serializing a tool result. */
|
||||
export function redactToJson(value: unknown): string {
|
||||
return JSON.stringify(redact(value));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { proposeTools } from "./propose-tools.js";
|
||||
import { readTools } from "./read-tools.js";
|
||||
import type { AiTool, ToolDefinitionShape } from "./types.js";
|
||||
|
||||
/**
|
||||
* The allowlist, and the security boundary for the whole feature.
|
||||
*
|
||||
* A model can only ever invoke what appears here. This matters more than usual
|
||||
* in this codebase: PermissionManager.requirePermission exists but is currently
|
||||
* mounted on zero routes, so RBAC strings are a vocabulary for the admin role
|
||||
* editor rather than route enforcement. "The assistant cannot reach credentials
|
||||
* or user administration" is therefore a property of this list, not of the
|
||||
* permission system.
|
||||
*
|
||||
* Anything touching credentials, vaults, RBAC, users, identity, certificates,
|
||||
* SSO or instance settings is deliberately absent and must stay absent.
|
||||
*/
|
||||
export const AI_TOOLS: AiTool[] = [...readTools, ...proposeTools];
|
||||
|
||||
const BY_NAME = new Map(AI_TOOLS.map((tool) => [tool.name, tool]));
|
||||
|
||||
export function getTool(name: string): AiTool | undefined {
|
||||
return BY_NAME.get(name);
|
||||
}
|
||||
|
||||
export function listToolNames(): string[] {
|
||||
return AI_TOOLS.map((tool) => tool.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domains the assistant must never be able to reach, in any tool, ever.
|
||||
* The catalog test asserts no tool name references these.
|
||||
*/
|
||||
export const FORBIDDEN_DOMAINS = [
|
||||
"credential",
|
||||
"vault",
|
||||
"rbac",
|
||||
"role",
|
||||
"permission",
|
||||
"user_admin",
|
||||
"password",
|
||||
"totp",
|
||||
"webauthn",
|
||||
"passkey",
|
||||
"api_key",
|
||||
"session",
|
||||
"oidc",
|
||||
"sso",
|
||||
"ldap",
|
||||
"termix_id",
|
||||
"identity",
|
||||
"certificate",
|
||||
"opkssh",
|
||||
"acme",
|
||||
"ssl",
|
||||
"audit",
|
||||
"sync",
|
||||
"settings",
|
||||
];
|
||||
|
||||
/** Tool definitions in the shape the provider adapters expect. */
|
||||
export function toolDefinitions(): ToolDefinitionShape[] {
|
||||
return AI_TOOLS.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Which commands the assistant may run without a per-command approval click,
|
||||
* for users who opted into read-only execution.
|
||||
*
|
||||
* The check parses the command into arguments and matches the resolved binary
|
||||
* against the allowlist. Substring matching would be trivially defeated
|
||||
* ("df; rm -rf /" contains "df"), so any shell metacharacter that could chain,
|
||||
* redirect or substitute a second command rejects the whole string outright.
|
||||
*/
|
||||
|
||||
export const READ_ONLY_COMMANDS = new Set([
|
||||
"df",
|
||||
"du",
|
||||
"free",
|
||||
"uptime",
|
||||
"uname",
|
||||
"whoami",
|
||||
"hostname",
|
||||
"id",
|
||||
"ps",
|
||||
"top",
|
||||
"systemctl",
|
||||
"journalctl",
|
||||
"docker",
|
||||
"ip",
|
||||
"ss",
|
||||
"netstat",
|
||||
"lsblk",
|
||||
"cat",
|
||||
"ls",
|
||||
"stat",
|
||||
"which",
|
||||
"date",
|
||||
"lscpu",
|
||||
"vmstat",
|
||||
"iostat",
|
||||
]);
|
||||
|
||||
/** Characters that let one command become several. */
|
||||
const SHELL_METACHARACTERS = /[;&|`$><\n\r\\]/;
|
||||
|
||||
/** Subcommands that are safe for otherwise-powerful binaries. */
|
||||
const SUBCOMMAND_ALLOWLIST: Record<string, Set<string>> = {
|
||||
systemctl: new Set([
|
||||
"status",
|
||||
"list-units",
|
||||
"list-unit-files",
|
||||
"is-active",
|
||||
"is-enabled",
|
||||
"show",
|
||||
]),
|
||||
docker: new Set([
|
||||
"ps",
|
||||
"stats",
|
||||
"images",
|
||||
"logs",
|
||||
"inspect",
|
||||
"version",
|
||||
"info",
|
||||
]),
|
||||
ip: new Set(["a", "addr", "link", "route", "neigh"]),
|
||||
};
|
||||
|
||||
/** Paths `cat` may read. Anything else could disclose credentials. */
|
||||
const CAT_ALLOWED_PREFIXES = ["/proc/", "/sys/", "/etc/os-release"];
|
||||
|
||||
export interface CommandCheck {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function isReadOnlyCommand(raw: string): CommandCheck {
|
||||
const command = raw.trim();
|
||||
if (!command) return { allowed: false, reason: "Empty command" };
|
||||
|
||||
if (SHELL_METACHARACTERS.test(command)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Command chaining, redirection and substitution are not allowed",
|
||||
};
|
||||
}
|
||||
|
||||
const parts = command.split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return { allowed: false, reason: "Empty command" };
|
||||
|
||||
// Reject env-prefixed and privilege-escalating forms outright.
|
||||
const head = parts[0];
|
||||
if (head === "sudo" || head === "su" || head === "doas" || head === "env") {
|
||||
return { allowed: false, reason: `${head} is not allowed` };
|
||||
}
|
||||
|
||||
// A path like /usr/bin/df resolves to its basename.
|
||||
const binary = head.includes("/")
|
||||
? head.slice(head.lastIndexOf("/") + 1)
|
||||
: head;
|
||||
|
||||
if (!READ_ONLY_COMMANDS.has(binary)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `${binary} is not on the read-only allowlist`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowedSubcommands = SUBCOMMAND_ALLOWLIST[binary];
|
||||
if (allowedSubcommands) {
|
||||
const subcommand = parts.slice(1).find((part) => !part.startsWith("-"));
|
||||
if (!subcommand || !allowedSubcommands.has(subcommand)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
`${binary} ${subcommand ?? ""}`.trim() +
|
||||
" is not on the read-only allowlist",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (binary === "cat") {
|
||||
const targets = parts.slice(1).filter((part) => !part.startsWith("-"));
|
||||
if (!targets.length) {
|
||||
return { allowed: false, reason: "cat needs a file path" };
|
||||
}
|
||||
for (const target of targets) {
|
||||
const permitted = CAT_ALLOWED_PREFIXES.some((prefix) =>
|
||||
prefix.endsWith("/") ? target.startsWith(prefix) : target === prefix,
|
||||
);
|
||||
if (!permitted) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `cat is limited to ${CAT_ALLOWED_PREFIXES.join(", ")}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSnippetRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { validateDefinition } from "../../database/routes/automations.js";
|
||||
import { resolveHostById } from "../../hosts/host-resolver.js";
|
||||
import { execCommand } from "../../hosts/metrics/widgets/common-utils.js";
|
||||
import {
|
||||
createFleetSshFactory,
|
||||
getFleetPoolKey,
|
||||
} from "../../hosts/ssh-client-factory.js";
|
||||
import { withConnection } from "../../hosts/ssh-connection-pool.js";
|
||||
import { getTool } from "./catalog.js";
|
||||
|
||||
/** Approved commands get a bounded window rather than hanging the request. */
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Applies an approved proposal.
|
||||
*
|
||||
* The stored payload is treated as untrusted input even though the server wrote
|
||||
* it: the proposal could have sat in the table across a release, and defending
|
||||
* the apply path rather than the create path means one place to get right.
|
||||
* Everything goes through the same repositories a human action uses, scoped to
|
||||
* the approving user.
|
||||
*/
|
||||
|
||||
export interface ApplyResult {
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
function requireNumber(value: unknown, field: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${field} must be a positive integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new Error(`${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
export async function applyProposal(
|
||||
kind: string,
|
||||
payload: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Promise<ApplyResult> {
|
||||
// A payload whose tool no longer exists is refused rather than guessed at.
|
||||
if (!getTool(kind)) {
|
||||
throw new Error(`Unknown proposal kind: ${kind}`);
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "propose_create_host": {
|
||||
const created = await createCurrentHostRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
ip: requireString(payload.ip, "ip"),
|
||||
port: Number(payload.port) || 22,
|
||||
username: optionalString(payload.username) ?? "",
|
||||
folder: optionalString(payload.folder) ?? "",
|
||||
tags: JSON.stringify(Array.isArray(payload.tags) ? payload.tags : []),
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created host ${(created as any).name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!existing) throw new Error("Host not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.ip !== undefined)
|
||||
updates.ip = requireString(changes.ip, "ip");
|
||||
if (changes.port !== undefined) updates.port = Number(changes.port);
|
||||
if (changes.username !== undefined)
|
||||
updates.username = optionalString(changes.username) ?? "";
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder) ?? "";
|
||||
if (changes.tags !== undefined) {
|
||||
updates.tags = JSON.stringify(
|
||||
Array.isArray(changes.tags) ? changes.tags : [],
|
||||
);
|
||||
}
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentHostRepository().updateForUser(
|
||||
userId,
|
||||
hostId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_host": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const deleted = await createCurrentHostRepository().deleteForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!deleted) throw new Error("Host not found");
|
||||
return { ok: true, summary: `Deleted host ${hostId}` };
|
||||
}
|
||||
|
||||
case "propose_create_snippet": {
|
||||
const created = await createCurrentSnippetRepository().createSnippet(
|
||||
userId,
|
||||
{
|
||||
name: requireString(payload.name, "name"),
|
||||
content: requireString(payload.content, "content"),
|
||||
description: optionalString(payload.description),
|
||||
folder: optionalString(payload.folder),
|
||||
} as any,
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created snippet ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_update_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const changes = (payload.changes ?? {}) as Record<string, unknown>;
|
||||
|
||||
const existing = await createCurrentSnippetRepository().findOwnedById(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!existing) throw new Error("Snippet not found");
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (changes.name !== undefined)
|
||||
updates.name = requireString(changes.name, "name");
|
||||
if (changes.content !== undefined)
|
||||
updates.content = requireString(changes.content, "content");
|
||||
if (changes.description !== undefined)
|
||||
updates.description = optionalString(changes.description);
|
||||
if (changes.folder !== undefined)
|
||||
updates.folder = optionalString(changes.folder);
|
||||
|
||||
if (!Object.keys(updates).length) {
|
||||
return { ok: false, summary: "Nothing to change" };
|
||||
}
|
||||
|
||||
await createCurrentSnippetRepository().updateSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
updates as any,
|
||||
);
|
||||
return { ok: true, summary: `Updated snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_delete_snippet": {
|
||||
const snippetId = requireNumber(payload.snippetId, "snippetId");
|
||||
const deleted = await createCurrentSnippetRepository().deleteSnippet(
|
||||
userId,
|
||||
snippetId,
|
||||
);
|
||||
if (!deleted) throw new Error("Snippet not found");
|
||||
return { ok: true, summary: `Deleted snippet ${snippetId}` };
|
||||
}
|
||||
|
||||
case "propose_create_fleet": {
|
||||
const fleet = await createCurrentFleetRepository().create(userId, {
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
} as any);
|
||||
|
||||
const hostIds = Array.isArray(payload.hostIds) ? payload.hostIds : [];
|
||||
let added = 0;
|
||||
for (const raw of hostIds) {
|
||||
const hostId = Number(raw);
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) continue;
|
||||
// Only hosts the approving user owns can join their fleet.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) continue;
|
||||
await createCurrentFleetRepository().addMember(
|
||||
(fleet as any).id,
|
||||
hostId,
|
||||
);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created fleet ${(fleet as any).name} with ${added} host${added === 1 ? "" : "s"}`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_alert_rule": {
|
||||
const created = await createCurrentAlertRepository().createAlertRule({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
hostId:
|
||||
payload.hostId === null || payload.hostId === undefined
|
||||
? null
|
||||
: requireNumber(payload.hostId, "hostId"),
|
||||
enabled: true,
|
||||
triggerType: requireString(payload.triggerType, "triggerType"),
|
||||
thresholdValue:
|
||||
payload.thresholdValue === null ||
|
||||
payload.thresholdValue === undefined
|
||||
? null
|
||||
: Number(payload.thresholdValue),
|
||||
thresholdDurationSeconds:
|
||||
payload.thresholdDurationSeconds === null ||
|
||||
payload.thresholdDurationSeconds === undefined
|
||||
? null
|
||||
: Number(payload.thresholdDurationSeconds),
|
||||
cooldownMinutes: Number(payload.cooldownMinutes) || 15,
|
||||
channelIds: [],
|
||||
} as any);
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created alert rule ${(created as any)?.name ?? ""}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_create_automation": {
|
||||
// Reuses the same validator the automations route runs, so an
|
||||
// LLM-authored definition is held to exactly the human standard.
|
||||
const validation = validateDefinition(payload.definition);
|
||||
if (!validation.ok || !validation.definition) {
|
||||
throw new Error(
|
||||
validation.error ?? "The automation definition is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
const created = await createCurrentAutomationRepository().create({
|
||||
userId,
|
||||
name: requireString(payload.name, "name"),
|
||||
description: optionalString(payload.description),
|
||||
definition: JSON.stringify(validation.definition),
|
||||
// Starts disabled: an automation the user has not watched run once
|
||||
// should not begin firing against their servers on approval.
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
summary: `Created automation ${(created as any).name} (disabled until you enable it)`,
|
||||
};
|
||||
}
|
||||
|
||||
case "propose_run_command": {
|
||||
const hostId = requireNumber(payload.hostId, "hostId");
|
||||
const command = requireString(payload.command, "command");
|
||||
|
||||
// resolveHostById, not the raw repository row: it runs the connect-level
|
||||
// permission check, decrypts auth under the owner's key, and resolves the
|
||||
// jump host chain. A plain row has none of that, so the SSH factory saw
|
||||
// an unresolved jumpHosts field and failed the connection.
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) throw new Error("Host not found");
|
||||
|
||||
const result = await runCommandOnHost(host, command);
|
||||
if (result.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
summary: result.output?.slice(0, 2000) ?? "(no output)",
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Proposal kind ${kind} cannot be applied automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one approved command over the shared SSH pool, mirroring how an
|
||||
* automation run_command step executes.
|
||||
*/
|
||||
async function runCommandOnHost(
|
||||
host: Record<string, any>,
|
||||
command: string,
|
||||
): Promise<{ output?: string; error?: string }> {
|
||||
try {
|
||||
const result = await withConnection(
|
||||
getFleetPoolKey(host as any),
|
||||
createFleetSshFactory(host as any),
|
||||
async (client) => execCommand(client, command, COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.code === 0 || result.code === null) {
|
||||
return { output: output || "(no output)" };
|
||||
}
|
||||
return { error: `Exited with code ${result.code}: ${output}`.trim() };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds the route handles itself rather than through applyProposal.
|
||||
* Empty: everything the assistant can propose can now be applied.
|
||||
*/
|
||||
export const ROUTE_APPLIED_KINDS = new Set<string>();
|
||||
@@ -0,0 +1,279 @@
|
||||
import { num, objectSchema, proposal, str, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Propose tools never mutate anything. They return a draft that is stored as a
|
||||
* pending proposal and rendered as a card; the change happens only when the
|
||||
* user approves it, and the payload is re-validated at that point.
|
||||
*/
|
||||
|
||||
export const proposeTools: AiTool[] = [
|
||||
{
|
||||
name: "propose_create_host",
|
||||
description:
|
||||
"Propose adding a new SSH host. Never include credentials: the user attaches those themselves after approving.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Display name for the host"),
|
||||
ip: str("Hostname or IP address"),
|
||||
port: num("SSH port (defaults to 22)"),
|
||||
username: str("SSH username"),
|
||||
folder: str("Folder to file the host under"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Tags to apply",
|
||||
},
|
||||
},
|
||||
["name", "ip"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_host", `Add host ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
ip: args.ip,
|
||||
port: args.port ?? 22,
|
||||
username: args.username ?? null,
|
||||
folder: args.folder ?? null,
|
||||
tags: args.tags ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_update_host",
|
||||
description:
|
||||
"Propose changing an existing host's non-secret settings. Only include the fields that should change.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to update"),
|
||||
name: str("New display name"),
|
||||
ip: str("New hostname or IP address"),
|
||||
port: num("New SSH port"),
|
||||
username: str("New SSH username"),
|
||||
folder: str("New folder"),
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Replacement tag list",
|
||||
},
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of [
|
||||
"name",
|
||||
"ip",
|
||||
"port",
|
||||
"username",
|
||||
"folder",
|
||||
"tags",
|
||||
]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_host",
|
||||
`Update host ${String(args.hostId)}`,
|
||||
{ hostId: args.hostId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_host",
|
||||
description:
|
||||
"Propose removing a host. Use sparingly and explain why in the reason.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to delete"),
|
||||
reason: str("Why this host should be removed"),
|
||||
},
|
||||
["hostId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_delete_host", `Delete host ${String(args.hostId)}`, {
|
||||
hostId: args.hostId,
|
||||
reason: args.reason,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_snippet",
|
||||
description:
|
||||
"Propose saving a new command snippet the user can run against their hosts.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Snippet name"),
|
||||
content: str("The command text"),
|
||||
description: str("What the snippet does"),
|
||||
folder: str("Folder to file it under"),
|
||||
},
|
||||
["name", "content"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_snippet",
|
||||
`Create snippet ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
content: args.content,
|
||||
description: args.description ?? null,
|
||||
folder: args.folder ?? null,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_update_snippet",
|
||||
description: "Propose editing an existing snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to update"),
|
||||
name: str("New name"),
|
||||
content: str("New command text"),
|
||||
description: str("New description"),
|
||||
folder: str("New folder"),
|
||||
},
|
||||
["snippetId"],
|
||||
),
|
||||
handler: async (args) => {
|
||||
const changes: Record<string, unknown> = {};
|
||||
for (const field of ["name", "content", "description", "folder"]) {
|
||||
if (args[field] !== undefined) changes[field] = args[field];
|
||||
}
|
||||
return proposal(
|
||||
"propose_update_snippet",
|
||||
`Update snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, changes },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "propose_delete_snippet",
|
||||
description: "Propose deleting a snippet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
snippetId: num("The snippet id to delete"),
|
||||
reason: str("Why this snippet should be removed"),
|
||||
},
|
||||
["snippetId", "reason"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_delete_snippet",
|
||||
`Delete snippet ${String(args.snippetId)}`,
|
||||
{ snippetId: args.snippetId, reason: args.reason },
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_automation",
|
||||
description:
|
||||
"Propose a new automation. The definition must be a valid AutomationDefinition object with a trigger and an ordered list of steps. It is validated server-side and previewed with a dry run before anything happens.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Automation name"),
|
||||
description: str("What the automation does"),
|
||||
definition: {
|
||||
type: "object",
|
||||
description:
|
||||
"The AutomationDefinition: { trigger: {...}, steps: [...] }",
|
||||
},
|
||||
},
|
||||
["name", "definition"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_automation",
|
||||
`Create automation ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
definition: args.definition,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_create_fleet",
|
||||
description: "Propose grouping hosts into a new fleet.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Fleet name"),
|
||||
description: str("What this fleet is for"),
|
||||
hostIds: {
|
||||
type: "array",
|
||||
items: { type: "number" },
|
||||
description: "Host ids to add as members",
|
||||
},
|
||||
},
|
||||
["name"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal("propose_create_fleet", `Create fleet ${String(args.name)}`, {
|
||||
name: args.name,
|
||||
description: args.description ?? null,
|
||||
hostIds: args.hostIds ?? [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "propose_create_alert_rule",
|
||||
description:
|
||||
"Propose a new alert rule that fires when a host metric crosses a threshold.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
name: str("Rule name"),
|
||||
hostId: num("Host id to watch, or omit to watch all hosts"),
|
||||
triggerType: str(
|
||||
"What to watch, for example cpu, memory, disk or host_status",
|
||||
),
|
||||
thresholdValue: num("Threshold to compare against"),
|
||||
thresholdDurationSeconds: num(
|
||||
"How long the breach must persist before firing",
|
||||
),
|
||||
cooldownMinutes: num("Minimum minutes between repeat firings"),
|
||||
},
|
||||
["name", "triggerType"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_create_alert_rule",
|
||||
`Create alert rule ${String(args.name)}`,
|
||||
{
|
||||
name: args.name,
|
||||
hostId: args.hostId ?? null,
|
||||
triggerType: args.triggerType,
|
||||
thresholdValue: args.thresholdValue ?? null,
|
||||
thresholdDurationSeconds: args.thresholdDurationSeconds ?? null,
|
||||
cooldownMinutes: args.cooldownMinutes ?? 15,
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "propose_run_command",
|
||||
description:
|
||||
"Propose running a command on a host. The user reviews and approves it before it runs. Use this for anything that changes state; read-only diagnostics may run directly if the user has enabled that.",
|
||||
category: "propose",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id to run on"),
|
||||
command: str("The exact command to run"),
|
||||
explanation: str("What the command does and why it is needed"),
|
||||
},
|
||||
["hostId", "command", "explanation"],
|
||||
),
|
||||
handler: async (args) =>
|
||||
proposal(
|
||||
"propose_run_command",
|
||||
// Deliberately short: the card renders the command in its own block,
|
||||
// so repeating it here made every card twice as tall as it needed.
|
||||
`Run a command on host ${String(args.hostId)}`,
|
||||
{
|
||||
hostId: args.hostId,
|
||||
command: args.command,
|
||||
explanation: args.explanation,
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
createCurrentAlertRepository,
|
||||
createCurrentAutomationRepository,
|
||||
createCurrentCommandHistoryRepository,
|
||||
createCurrentFleetRepository,
|
||||
createCurrentHomepageItemRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentNetworkTopologyRepository,
|
||||
createCurrentSnippetRepository,
|
||||
createCurrentWorkspaceRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { num, objectSchema, type AiTool } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read tools project explicit fields rather than spreading rows. Redaction runs
|
||||
* afterwards as a second line of defense, but the projection here is the
|
||||
* primary control: a field that is never selected cannot leak.
|
||||
*/
|
||||
|
||||
interface HostSummary {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
folder: string | null;
|
||||
tags: unknown;
|
||||
protocol: string | null;
|
||||
enableTerminal: boolean | null;
|
||||
enableFileManager: boolean | null;
|
||||
enableTunnel: boolean | null;
|
||||
enableDocker: boolean | null;
|
||||
}
|
||||
|
||||
function toHostSummary(host: Record<string, any>): HostSummary {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name ?? null,
|
||||
ip: host.ip ?? null,
|
||||
port: host.port ?? null,
|
||||
username: host.username ?? null,
|
||||
folder: host.folder ?? null,
|
||||
tags: host.tags ?? null,
|
||||
protocol: host.protocol ?? null,
|
||||
enableTerminal: host.enableTerminal ?? null,
|
||||
enableFileManager: host.enableFileManager ?? null,
|
||||
enableTunnel: host.enableTunnel ?? null,
|
||||
enableDocker: host.enableDocker ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const readTools: AiTool[] = [
|
||||
{
|
||||
name: "list_hosts",
|
||||
description:
|
||||
"List the user's SSH hosts with their names, addresses, folders and tags. Never returns passwords or keys. Call this before proposing anything that references a host.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const hosts = await createCurrentHostRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return { hosts: hosts.map((host) => toHostSummary(host as any)) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_host",
|
||||
description:
|
||||
"Get one host's non-secret configuration by id. Use it to inspect settings before proposing an update.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{ hostId: num("The host id, as returned by list_hosts") },
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const hostId = Number(args.hostId);
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
return { host: toHostSummary(host as any) };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_fleets",
|
||||
description:
|
||||
"List the user's fleets. Fleets group hosts for bulk operations and inventory.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const fleets = await createCurrentFleetRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
fleets: fleets.map((fleet: any) => ({
|
||||
id: fleet.id,
|
||||
name: fleet.name,
|
||||
description: fleet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_snippets",
|
||||
description:
|
||||
"List the user's saved command snippets, including their folder and the command text.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const snippets = await createCurrentSnippetRepository().listOwnedSnippets(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
snippets: snippets.map((snippet: any) => ({
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
folder: snippet.folder ?? null,
|
||||
description: snippet.description ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_automations",
|
||||
description:
|
||||
"List the user's automations with their trigger kind and enabled state. Read this before proposing a change to an existing automation.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const automations = await createCurrentAutomationRepository().list(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
automations: automations.map((automation: any) => ({
|
||||
id: automation.id,
|
||||
name: automation.name,
|
||||
description: automation.description ?? null,
|
||||
enabled: automation.enabled,
|
||||
lastRunAt: automation.lastRunAt ?? null,
|
||||
lastRunStatus: automation.lastRunStatus ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_automation",
|
||||
description:
|
||||
"Get one automation's full definition (trigger and steps) by id.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
automationId: num("The automation id, as returned by list_automations"),
|
||||
},
|
||||
["automationId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const automation = await createCurrentAutomationRepository().findForUser(
|
||||
Number(args.automationId),
|
||||
context.userId,
|
||||
);
|
||||
if (!automation) return { error: "Automation not found" };
|
||||
return {
|
||||
automation: {
|
||||
id: (automation as any).id,
|
||||
name: (automation as any).name,
|
||||
enabled: (automation as any).enabled,
|
||||
definition: (automation as any).definition,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_workspaces",
|
||||
description:
|
||||
"List the user's saved workspace layouts (named sets of open tabs and splits).",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const workspaces = await createCurrentWorkspaceRepository().listByUser(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
workspaces: workspaces.map((workspace: any) => ({
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
isDefault: workspace.isDefault ?? false,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_alert_rules",
|
||||
description:
|
||||
"List the user's alert rules with their thresholds and enabled state.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const rules = await createCurrentAlertRepository().listAlertRules(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
rules: rules.map((rule) => ({
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
hostId: rule.host_id,
|
||||
enabled: rule.enabled === 1,
|
||||
triggerType: rule.trigger_type,
|
||||
thresholdValue: rule.threshold_value,
|
||||
thresholdDurationSeconds: rule.threshold_duration_seconds,
|
||||
cooldownMinutes: rule.cooldown_minutes,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_notification_channels",
|
||||
description:
|
||||
"List the user's notification channels by id, name and type. Channel configuration is never returned because it holds tokens.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const channels =
|
||||
await createCurrentAlertRepository().listNotificationChannels(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
channels: channels.map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
type: channel.type,
|
||||
enabled: channel.enabled === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_alert_firings",
|
||||
description:
|
||||
"Recent alert firings, newest first. Use this to answer questions about what has been alerting.",
|
||||
category: "read",
|
||||
parameters: objectSchema({
|
||||
limit: num("How many firings to return (default 25, max 100)"),
|
||||
}),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const result = await createCurrentAlertRepository().listAlertFirings({
|
||||
userId: context.userId,
|
||||
limit,
|
||||
offset: 0,
|
||||
});
|
||||
return {
|
||||
firings: (result.firings ?? []).map((firing) => ({
|
||||
id: firing.id,
|
||||
ruleName: firing.rule_name,
|
||||
hostName: firing.host_name,
|
||||
firedAt: firing.fired_at,
|
||||
resolvedAt: firing.resolved_at,
|
||||
severity: firing.severity,
|
||||
message: firing.message,
|
||||
acknowledged: firing.acknowledged === 1,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_homepage_items",
|
||||
description: "List the user's homepage service-link tiles.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const items = await createCurrentHomepageItemRepository().listByUserId(
|
||||
context.userId,
|
||||
);
|
||||
return {
|
||||
items: (items as any[]).map((item) => ({
|
||||
id: item.id,
|
||||
typeId: item.typeId,
|
||||
title: item.title ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_command_history",
|
||||
description:
|
||||
"Recent commands the user has run on one host, newest first. Useful for understanding what they have been working on.",
|
||||
category: "read",
|
||||
parameters: objectSchema(
|
||||
{
|
||||
hostId: num("The host id, as returned by list_hosts"),
|
||||
limit: num("How many entries to return (default 25, max 100)"),
|
||||
},
|
||||
["hostId"],
|
||||
),
|
||||
handler: async (args, context) => {
|
||||
const limit = Math.min(Math.max(Number(args.limit) || 25, 1), 100);
|
||||
const hostId = Number(args.hostId);
|
||||
|
||||
// Ownership is enforced here rather than trusted from the model.
|
||||
const host = await createCurrentHostRepository().findByIdForUser(
|
||||
context.userId,
|
||||
hostId,
|
||||
);
|
||||
if (!host) return { error: "Host not found" };
|
||||
|
||||
const commands =
|
||||
await createCurrentCommandHistoryRepository().listCommandsForHost(
|
||||
context.userId,
|
||||
hostId,
|
||||
limit,
|
||||
);
|
||||
return { commands };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_network_topology",
|
||||
description: "The user's saved network topology graph, if they have one.",
|
||||
category: "read",
|
||||
parameters: objectSchema({}),
|
||||
handler: async (_args, context) => {
|
||||
const topology =
|
||||
await createCurrentNetworkTopologyRepository().findByUserId(
|
||||
context.userId,
|
||||
);
|
||||
if (!topology) return { topology: null };
|
||||
return { topology: (topology as any).data ?? null };
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,72 @@
|
||||
export type ToolCategory = "read" | "propose";
|
||||
|
||||
export interface ToolContext {
|
||||
/** Always taken from the verified JWT, never from model input. */
|
||||
userId: string;
|
||||
conversationId: number;
|
||||
/** Per-user opt-in for running allowlisted read-only commands. */
|
||||
allowReadOnlyCommands: boolean;
|
||||
}
|
||||
|
||||
export interface AiTool {
|
||||
name: string;
|
||||
description: string;
|
||||
category: ToolCategory;
|
||||
/** JSON Schema for the arguments, sent to the provider verbatim. */
|
||||
parameters: Record<string, unknown>;
|
||||
/**
|
||||
* Read tools return data to feed back to the model. Propose tools return a
|
||||
* ProposalDraft and must not mutate anything.
|
||||
*/
|
||||
handler: (
|
||||
args: Record<string, unknown>,
|
||||
context: ToolContext,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a provider adapter needs to describe a tool to its model. */
|
||||
export interface ToolDefinitionShape {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ProposalDraft {
|
||||
__proposal: true;
|
||||
kind: string;
|
||||
summary: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function isProposalDraft(value: unknown): value is ProposalDraft {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as ProposalDraft).__proposal === true
|
||||
);
|
||||
}
|
||||
|
||||
export function proposal(
|
||||
kind: string,
|
||||
summary: string,
|
||||
payload: Record<string, unknown>,
|
||||
): ProposalDraft {
|
||||
return { __proposal: true, kind, summary, payload };
|
||||
}
|
||||
|
||||
/** Small helper so tool schemas stay readable. */
|
||||
export function objectSchema(
|
||||
properties: Record<string, unknown>,
|
||||
required: string[] = [],
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required,
|
||||
additionalProperties: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const str = (description: string) => ({ type: "string", description });
|
||||
export const num = (description: string) => ({ type: "number", description });
|
||||
export const bool = (description: string) => ({ type: "boolean", description });
|
||||
Reference in New Issue
Block a user