fix: bundled plugins path for source runs and backend cleanup

This commit is contained in:
LukeGus committed 2026-10-09 07:37:46 -05:00
1 parent b0a0ee3e00
commit f803bfc140
51 files changed
+147 -163

No files matched your search

+2 -2
View File
@@ -1041,7 +1041,7 @@ function getBackendPaths() {
backendCwd: backendDir,
};
}
// fork() does not go through Electron's asar redirector — use the unpacked path.
// fork() does not go through Electron's asar redirector, use the unpacked path.
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
const unpackedRoot = getUnpackedAppRoot(appRoot);
@@ -1303,7 +1303,7 @@ function createTray() {
try {
const { nativeImage } = require("electron");
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
// Native APIs (Tray, nativeImage) can't load files from inside app.asar,
// use the unpacked path so the OS sees a real file.
const publicRoot = isDev
? path.join(appRoot, "public")
+2 -2
View File
@@ -280,12 +280,12 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
* remoteVersion:
* type: string
* description: Newest release version on the channel. Present only when the update check succeeds.
* example: 2.8.0
* example: 26.10.0
* version:
* type: string
* deprecated: true
* description: Legacy alias of remoteVersion, not the running instance's version. Present only when the update check succeeds. Use localVersion for the instance or remoteVersion for the latest release.
* example: 2.8.0
* example: 26.10.0
* latest_release:
* type: object
* description: GitHub release metadata. Present only when the update check succeeds.
+3 -3
View File
@@ -50,7 +50,7 @@ export function sslOption(
/**
* Opens a connection to a client-server engine.
*
* SQLite is not handled here — it has its own lifecycle in db/index.ts, where
* SQLite is not handled here, it has its own lifecycle in db/index.ts, where
* the database is decrypted into memory and serialised back to a file. This
* covers the engines that connect to something already running.
*
@@ -109,7 +109,7 @@ export async function connectRemoteDatabase(
// No `schema` option: it only feeds drizzle's relational query API
// (`db.query.*`), which nothing here uses. The query builder takes its table
// names and value encoders from the table objects the repositories import —
// names and value encoders from the table objects the repositories import,
// see the note in schema.pg.ts on why the generated schemas are DDL-only.
if (dialect === "postgres") {
const { drizzle } = await import("drizzle-orm/node-postgres");
@@ -119,7 +119,7 @@ export async function connectRemoteDatabase(
}
// mysql2 names the pool limit differently and wants no `ssl` key at all when
// TLS is off — passing false is not the same as omitting it.
// TLS is off, passing false is not the same as omitting it.
const { drizzle } = await import("drizzle-orm/mysql2");
return drizzle({
connection: {
+1 -1
View File
@@ -42,7 +42,7 @@ export function resolveDatabaseDialect(
*
* SQLite here is an in-memory database serialised back to an encrypted file, so
* every write needs a trigger to flush it. Client-server engines have already
* durably committed by the time the query returns — there is no file to write
* durably committed by the time the query returns, there is no file to write
* and nothing to schedule.
*/
export function needsExplicitPersist(dialect: DatabaseDialect): boolean {
+2 -2
View File
@@ -1103,7 +1103,7 @@ const migrateSchema = () => {
// Derive the replacement table from the live definition instead of
// restating it here. The table keeps gaining columns (cert_public_key,
// pin, sort_order, sync_id, ...), and a second copy of the column list
// falls behind every time one is added — leaving the copy narrower than
// falls behind every time one is added, leaving the copy narrower than
// the table, so the INSERT below fails and the constraint stays put.
const createSql = sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
@@ -2108,7 +2108,7 @@ export function getSqlite(): Database.Database {
if (dialect !== "sqlite") {
throw new Error(
`No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
`synchronous query, which only SQLite offers — give it an async path instead.`,
`synchronous query, which only SQLite offers. Give it an async path instead.`,
);
}
throw new Error(
+1 -1
View File
@@ -25,7 +25,7 @@ export function migrationsFolder(
* Brings a remote database up to the current schema.
*
* drizzle's migrator records what it has applied in its own table, so this is
* safe to run on every start — including against a database another instance
* safe to run on every start, including against a database another instance
* already migrated.
*/
export async function runRemoteMigrations(
+5 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit.
// GENERATED FILE, do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: mysql.
//
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
import {
@@ -542,7 +542,7 @@ export const sharedHostSecrets = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -592,7 +592,7 @@ export const userRoles = mysqlTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1060,7 @@ export const pluginRegistries = mysqlTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = mysqlTable(
+5 -5
View File
@@ -1,10 +1,10 @@
// GENERATED FILE — do not edit.
// GENERATED FILE, do not edit.
//
// Produced from schema.ts by scripts/generate-dialect-schema.cjs.
// Edit the sqlite schema and re-run `node scripts/generate-dialect-schema.cjs`.
// Target dialect: postgres.
//
// DDL source for drizzle-kit. NOT imported to run queries — repositories use
// DDL source for drizzle-kit. NOT imported to run queries. Repositories use
// schema.ts on every dialect. See the generator header for why that is correct.
import {
@@ -542,7 +542,7 @@ export const sharedHostSecrets = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -592,7 +592,7 @@ export const userRoles = pgTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1060,7 +1060,7 @@ export const pluginRegistries = pgTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = pgTable(
+3 -3
View File
@@ -534,7 +534,7 @@ export const sharedHostSecrets = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
(table) => [
uniqueIndex("idx_shared_host_secrets_scope").on(
table.hostAccessId,
@@ -584,7 +584,7 @@ export const userRoles = sqliteTable(
},
// Declared inline in the production DDL as UNIQUE(...), but never here,
// so the generated Postgres and MySQL schemas allowed duplicates the
// SQLite deployment forbids — and the upsert had nothing to conflict on.
// SQLite deployment forbids, and the upsert had nothing to conflict on.
//
// The unique pair already serves lookups by user, since user_id leads it.
// Listing a role's members starts from role_id, which it cannot serve.
@@ -1058,7 +1058,7 @@ export const pluginRegistries = sqliteTable("plugin_registries", {
/**
* Install counts populated by a background job (GitHub release download
* counts, aggregated telemetry, or a manual override) rather than by the
* install/uninstall actions themselves — kept separate from `plugins` so
* install/uninstall actions themselves, kept separate from `plugins` so
* that job can overwrite counts without touching install state.
*/
export const pluginInstallCounts = sqliteTable(
@@ -74,7 +74,7 @@ export class AuditLogRepository {
/**
* Cached row count backing the cap check. Static because the factory builds
* a repository per call, so a per-instance count would never survive to be
* reused. Null means "unknown, re-read" — which is also how any path that
* reused. Null means "unknown, re-read", which is also how any path that
* deletes rows invalidates it.
*/
private static cachedCount: number | null = null;
@@ -233,13 +233,13 @@ export class AuditLogRepository {
*
* Both passes used to run inline on every insert: a retention DELETE plus a
* COUNT over the whole table, thousands of times an hour on a busy install,
* almost always to find nothing to do — and audit writes sit in the request
* almost always to find nothing to do, and audit writes sit in the request
* path of the actions they record.
*
* They are split by what they cost and what they guarantee. Retention is
* time-based, so nothing is lost by checking it on an interval. The row cap
* is a disk-space guard that has to react to inserts, so it is still checked
* on the write that crosses it — but against a cached count, so the common
* on the write that crosses it, but against a cached count, so the common
* case is an integer compare rather than a COUNT.
*/
private async pruneIfDue(): Promise<void> {
@@ -2,7 +2,7 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type * as schema from "../db/schema.js";
// Re-exported so repositories can keep importing it from here, but defined in
// db/dialect.ts — a local copy that said "sqlite" survived here for a while and
// db/dialect.ts, a local copy that said "sqlite" survived here for a while and
// typed every context as SQLite-only while the runtime already carried all
// three, which silently made the dialect branches unreachable to the checker.
export type { DatabaseDialect } from "../db/dialect.js";
@@ -18,7 +18,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
*
* This is a deliberate approximation, not an accident. The query-builder
* surface the repositories actually use is the same on all three engines, and
* that equivalence is asserted in multi-dialect.test.ts rather than assumed —
* that equivalence is asserted in multi-dialect.test.ts rather than assumed,
* identifier quoting, placeholder style and value coercion are all covered
* there. At runtime this may hold a Postgres or MySQL instance.
*
@@ -32,7 +32,7 @@ export type PortableDatabase = BetterSQLite3Database<typeof schema>;
*
* Deliberately drizzle-only: with no raw driver handle here, no repository can
* reach for engine-specific SQL. Retention queries that previously needed
* `datetime('now', ?)` compute their cutoff in JS instead — see
* `datetime('now', ?)` compute their cutoff in JS instead, see
* ./sql-timestamp.ts.
*/
export interface DatabaseContext {
+5 -5
View File
@@ -49,7 +49,7 @@ import { UserRepository } from "./user-repository.js";
* write.
*
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function — see tests/database/repositories/factory-context.
* exercises this function, see tests/database/repositories/factory-context.
*/
export function createCurrentRepositoryContext(): DatabaseContext {
return {
@@ -64,7 +64,7 @@ export function createCurrentRepositoryContext(): DatabaseContext {
* Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does
* nothing — repositories call it as `this.onWrite?.()`.
* nothing, repositories call it as `this.onWrite?.()`.
*/
export function createCurrentRepositoryWriteHook(
reason: string,
@@ -91,7 +91,7 @@ export function createCurrentRepositoryLazyWriteHook(
}
/**
* Raw driver handle for the few synchronous call sites that cannot await —
* Raw driver handle for the few synchronous call sites that cannot await,
* getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first.
@@ -403,8 +403,8 @@ let refreshTimer: NodeJS.Timeout | null = null;
*
* The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and
* MySQL — which exist here precisely so more than one instance can share the
* data — a setting changed on one replica would otherwise never reach the
* MySQL, which exist here precisely so more than one instance can share the
* data, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database.
*
* Periodic re-priming does not make the value immediately consistent. It bounds
@@ -31,7 +31,7 @@ export class HostFolderRepository {
now = new Date().toISOString(),
): Promise<RenameFolderResult> {
// CAST target: every engine spells the text type differently enough to
// matter here — MySQL has no `text` cast and wants `char`.
// matter here, MySQL has no `text` cast and wants `char`.
const textType = this.context.dialect === "mysql" ? "char" : "text";
const oldPrefix = `${oldName} / `;
const newPrefix = `${newName} / `;
@@ -4,18 +4,18 @@ import type { DatabaseDialect } from "../db/dialect.js";
* Reading the outcome of a write without depending on RETURNING.
*
* SQLite and Postgres can attach `.returning()` to a delete or update and get
* the affected rows back. **MySQL cannot** — it has no RETURNING clause, and
* the affected rows back. **MySQL cannot**: it has no RETURNING clause, and
* drizzle's mysql-core does not expose the method at all, so the call is a
* TypeError rather than a bad query. 175 call sites here read a write's result,
* so the difference has to be absorbed somewhere.
*
* The split that matters is what the caller actually needs:
*
* - **How many rows changed** — the majority, and none of them need the rows.
* - **How many rows changed**: the majority, and none of them need the rows.
* They used to ask for them anyway, via `.returning().length`. Dropping the
* `.returning()` and reading the driver's own count is both portable and one
* less thing for the database to send back.
* - **The rows themselves** — cannot be emulated on MySQL without reading
* - **The rows themselves**: cannot be emulated on MySQL without reading
* first, which needs a transaction to stay correct under concurrency. Those
* call sites are handled individually rather than behind a helper that hides
* an extra round trip.
@@ -33,7 +33,7 @@ import type { DatabaseDialect } from "../db/dialect.js";
* | node-postgres | `{ rowCount, rows, command }` |
* | mysql2 | `[{ affectedRows, insertId }, fields]` |
*
* These are the shapes returned when NO `.returning()` is attached — which is
* These are the shapes returned when NO `.returning()` is attached, which is
* the portable way to write, since MySQL has no RETURNING clause at all.
*/
interface WriteHeader {
@@ -47,7 +47,7 @@ interface WriteHeader {
const COUNT_FIELDS = ["changes", "rowCount", "affectedRows"] as const;
/**
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array — so
* mysql2 hands back `[ResultSetHeader, fields]`, which is itself an array, so
* "is it an array" cannot distinguish a write header from a returning() result.
* The header is identified by carrying one of the fields above instead.
*/
@@ -70,7 +70,7 @@ function asWriteHeader(result: unknown): WriteHeader | null {
/**
* Number of rows a write touched.
*
* Pass the result of the write itself — every driver's header is understood, so
* Pass the result of the write itself, every driver's header is understood, so
* the caller neither branches on the dialect nor attaches `.returning()` just to
* count what came back.
*
@@ -97,7 +97,7 @@ export function rowsAffected(result: unknown): number {
*
* **Only meaningful on the result of an insert.** SQLite's `lastInsertRowid` and
* MySQL's `insertId` are connection-level values that survive the statement that
* set them — after a delete, SQLite still reports whatever the last insert
* set them, after a delete, SQLite still reports whatever the last insert
* produced. Passing an update or delete result here gets a stale id, not null.
*
* Returns null when the table has no autoincrement key.
@@ -143,7 +143,7 @@ export function supportsReturning(dialect: DatabaseDialect): boolean {
*
* `sql<number>` is a type assertion, not a conversion. Postgres returns COUNT()
* as bigint, which node-postgres hands back as a **string** so that values past
* 2^53 survive — so the annotation is a lie there and comparisons like
* 2^53 survive, so the annotation is a lie there and comparisons like
* `count < max` compare a string to a number.
*/
export function countValue(value: unknown): number {
+12 -12
View File
@@ -11,15 +11,15 @@ import {
* Writes that need the affected rows back.
*
* `mutation-result.ts` covers the call sites that only wanted a count. These are
* the ones that genuinely read the rows — an updated record to return to the
* the ones that genuinely read the rows, an updated record to return to the
* caller, a deleted row's fields to clean up alongside it.
*
* SQLite and Postgres do this in one statement with RETURNING. MySQL has no
* such clause, so the read is a second statement, and the pair has to be atomic:
*
* - **insert** — write, then read the row back by its key.
* - **update** — write, then read. Reading first would return the old values.
* - **delete** — read, then write. Reading after would return nothing.
* - **insert**: write, then read the row back by its key.
* - **update**: write, then read. Reading first would return the old values.
* - **delete**: read, then write. Reading after would return nothing.
*
* Both run in a transaction. Without one, a concurrent write between the two
* statements makes the returned rows describe a state that never existed, and
@@ -29,7 +29,7 @@ import {
* ## The trap, and why it cannot bite silently
*
* On MySQL the update path re-reads using the same `where`. If the update
* changes a column that `where` tests, the read finds nothing — SQLite would
* changes a column that `where` tests, the read finds nothing, SQLite would
* have returned the row. Every current caller filters on an id it does not
* modify, but that is a convention, not a guarantee, so the mismatch is
* detected and thrown rather than returned as an empty array. Same for an
@@ -40,7 +40,7 @@ import {
*/
/**
* What `.set()` accepts: a column's own type, or a SQL expression in its place —
* What `.set()` accepts: a column's own type, or a SQL expression in its place,
* `updatedAt: sql`CURRENT_TIMESTAMP`` is the common one here.
*/
type UpdateValues<T extends SQLiteTable> = {
@@ -68,7 +68,7 @@ export async function updateReturning<T extends SQLiteTable>(
const rows = await tx.select().from(table).where(where);
// The trap this catches: if the update changed a column that `where` tests,
// the read finds nothing and the caller gets [] — on MySQL only, with no
// the read finds nothing and the caller gets [], on MySQL only, with no
// error, where SQLite would have returned the row. Rows changed but none
// readable back is exactly that case, so make it loud instead.
if (rows.length === 0 && rowsAffected(written) > 0) {
@@ -108,10 +108,10 @@ type Keyed = SQLiteTable & { id: SQLiteColumn };
/**
* Inserts one row and returns it as stored, including whatever the database
* filled in — defaults, an autoincrement id, a CURRENT_TIMESTAMP.
* filled in, defaults, an autoincrement id, a CURRENT_TIMESTAMP.
*
* This is the one case Postgres cannot shortcut either: without RETURNING there
* is no id to read back by. Hence the split is genuinely three-way — except
* is no id to read back by. Hence the split is genuinely three-way, except
* that sqlite and pg both have RETURNING, so it collapses to two again.
*
* On MySQL the key comes from one of two places:
@@ -161,7 +161,7 @@ export async function insertReturning<T extends Keyed>(
* back by an explicit condition.
*
* `user_preferences` is keyed by `userId` and has no `id` column at all, so
* there is no insertId to read back by — the caller has to say what identifies
* there is no insertId to read back by, the caller has to say what identifies
* the row it just wrote.
*/
export async function insertReturningWhere<T extends SQLiteTable>(
@@ -195,13 +195,13 @@ export async function insertReturningWhere<T extends SQLiteTable>(
*
* The clause has three spellings. SQLite and Postgres take
* `ON CONFLICT (cols) DO UPDATE`; **MySQL takes `ON DUPLICATE KEY UPDATE` and
* names no columns** — it uses whichever unique key was violated. drizzle
* names no columns**: it uses whichever unique key was violated. drizzle
* follows suit, so `onConflictDoUpdate` does not exist on mysql-core at all and
* calling it is a TypeError rather than a rejected query.
*
* The conflict target still has to be passed: it is what SQLite and Postgres
* need, and stating it keeps the caller honest about which unique constraint it
* is relying on — four of those were missing from the schema entirely until the
* is relying on, four of those were missing from the schema entirely until the
* cross-dialect tests went looking.
*/
export async function upsert<T extends SQLiteTable>(
@@ -1,7 +1,7 @@
/**
* Synchronous read-through cache for the settings table.
*
* 27 call sites read settings synchronously — during startup, inside request
* 27 call sites read settings synchronously, during startup, inside request
* handlers, and from the guacd server bootstrap. On SQLite that works because
* better-sqlite3 is synchronous; on Postgres or MySQL there is no synchronous
* query at all, and making all 27 async would push `await` through code paths
@@ -35,7 +35,7 @@ export function primeSettingsCache(
/**
* Reads a cached setting.
*
* Returns null both for "not set" and "cache not primed yet" — every caller
* Returns null both for "not set" and "cache not primed yet", every caller
* already treats a missing setting as "use the default", and startup ordering
* means a read before priming should behave the same way rather than throw.
*/
@@ -141,8 +141,8 @@ export class UserRepository {
* at once would otherwise both see an empty table and both become admin.
*
* The two branches are not a style choice. better-sqlite3 is synchronous and
* rejects an async transaction callback outright — "Transaction function
* cannot return a promise" — so a single body cannot serve both. It fails
* rejects an async transaction callback outright, "Transaction function
* cannot return a promise", so a single body cannot serve both. It fails
* loudly rather than silently skipping the write, which is the one mercy here.
*/
private async createCheckingIfFirst(
@@ -17,7 +17,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
* /credential-sidebar/preferences:
* get:
* summary: Get the credential sidebar preferences for the current user
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here — credentials never had exploded preference columns on userPreferences — so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* description: Returns the current user's saved credential sidebar preferences (sort, filters, open folders, display settings). Unlike /host-sidebar/preferences, there is no legacy-column migration to perform here, credentials never had exploded preference columns on userPreferences, so a first-time GET simply returns the defaults without writing a row; a row is only created once the user actually changes something via PUT.
* tags:
* - Credential Sidebar
* responses:
+8 -8
View File
@@ -9,6 +9,14 @@ const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
/**
* @openapi
* /open-tabs:
@@ -20,14 +28,6 @@ const authenticateJWT = authManager.createAuthMiddleware();
* 200:
* description: List of open tabs ordered by tab_order.
*/
const DEFAULT_TAB_TTL_MINUTES = 30;
// Tabs live as long as the terminal keeps a detached session.
function getTabTtlMs(): number {
const minutes = liveTerminalSessions.idleTimeoutMinutes();
return (minutes && minutes > 0 ? minutes : DEFAULT_TAB_TTL_MINUTES) * 60_000;
}
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
+5 -2
View File
@@ -17,7 +17,7 @@ import { describePluginFrontend } from "../../plugins/assets.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import { MAX_UPLOAD_BYTES, PluginManageError } from "../../plugins/manage.js";
import { parsePluginChannel } from "../../plugins/registry-index.js";
import {
pluginDocsUrl,
@@ -2262,7 +2262,10 @@ router.post(
"/upload",
authenticateJWT,
requireManagePlugins,
express.raw({ type: "application/octet-stream", limit: "50mb" }),
express.raw({
type: "application/octet-stream",
limit: MAX_UPLOAD_BYTES,
}),
async (req: Request, res: Response) => {
if (await refuseOnLinkedDesktop(res)) return;
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
+1 -1
View File
@@ -16,7 +16,7 @@ export function normalizeHostAddress(value: unknown): string {
* A client addresses a host by the numeric row id of the database it is
* displaying. When the desktop app delegates a connection to a remote sync
* server, the id is resolved against *that* server's table instead, and
* autoincrement ids need not line up between the two — they diverge as soon as
* autoincrement ids need not line up between the two, they diverge as soon as
* the sides accumulate inserts and deletes in a different order.
*
* The resolved row then supplies the address, the credentials, the jump hosts
+1 -1
View File
@@ -25,7 +25,7 @@ const sshLogger = logger;
* `id` is an autoincrement belonging to whichever database produced the row.
* When the desktop app delegates a connection to a sync server, the two
* sequences have no reason to agree, and resolving the client's id here lands
* on whatever host happens to own that number — a different machine, with its
* on whatever host happens to own that number, a different machine, with its
* own address, credentials and host key. `syncId` is the same string on both
* sides, so it names the host the user actually picked.
*
@@ -8,10 +8,7 @@
* needs no core change.
*/
import {
createCurrentHostProtocolAuthRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../../database/repositories/factory.js";
import { createCurrentHostProtocolAuthRepository } from "../../database/repositories/factory.js";
import {
decryptProtocolLogin,
type HostProtocolAuthRecord,
@@ -605,17 +602,6 @@ export async function resolveRecipientProtocolLogin(
};
}
/** Credential ids a recipient chose for this host, per protocol. */
export async function listRecipientOverrideIds(
hostId: number,
userId: string,
): Promise<Record<string, number>> {
return (await createCurrentSharedHostAuthOverrideRepository().listCredentialIds(
hostId,
userId,
)) as Record<string, number>;
}
/** A login in an export or on the sync wire: plaintext, keyed by protocol. */
export interface PortableProtocolLogin {
authType: string;
+1 -1
View File
@@ -169,7 +169,7 @@ export class SSHConnectionPool {
if (available) {
if (!this.isConnectionHealthy(available.client)) {
connections = this.removeUnhealthy(key, connections, available);
// Fall through — may create or wait again.
// Fall through, may create or wait again.
} else {
const waiter = queue.shift()!;
if (queue.length === 0) this.waiters.delete(key);
-5
View File
@@ -61,8 +61,3 @@ export function startPluginAutoUpdate(): void {
timer = setInterval(run, CHECK_EVERY_MS);
timer.unref?.();
}
export function stopPluginAutoUpdate(): void {
if (timer) clearInterval(timer);
timer = null;
}
+9 -18
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
@@ -43,29 +42,21 @@ export function getUnpackedPluginsDir(): string {
* plugin is part of the install and must not be deletable or shadowable by
* whatever happens to be in the data directory.
*
* Resolved from this module's location rather than cwd, because the build
* emits to dist/backend/backend/plugins/ and the bundled plugins sit beside
* dist/backend.
* Resolved from this module's location rather than cwd. The build emits to
* dist/backend/backend/plugins/ with the bundled plugins beside dist/backend,
* and a run from source reads the same dist/plugins.
*/
export function getBundledPluginsDir(): string {
const override = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
if (override) return override;
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Built server: dist/backend/backend/plugins -> dist/plugins
// Source tree: src/backend/plugins -> plugins
// Both are three levels up, which is why there is one entry, not two.
path.resolve(here, "../../../plugins"),
// Built server invoked from a nested layout: .../dist/backend/backend/plugins
path.resolve(here, "../../../../plugins"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return candidates[0];
// Built: dist/backend/backend/plugins -> dist/plugins
// Source (vitest, tsx): src/backend/plugins -> dist/plugins
const fromSource = path.basename(path.resolve(here, "../..")) === "src";
return fromSource
? path.resolve(here, "../../../dist/plugins")
: path.resolve(here, "../../../plugins");
}
/** The backend entry, from the manifest's own `backend` field. */
-4
View File
@@ -371,10 +371,6 @@ function disposeRoute(routeKey: string, pluginId: string, path: string): void {
});
}
export function getRegisteredWsRoutes(): string[] {
return [...routes.keys()];
}
/** The socket paths a plugin serves without core's login check. */
export function getPluginPublicWsRoutes(pluginId: string): string[] {
return [...routes.values()]
-5
View File
@@ -126,11 +126,6 @@ export async function reconcileUser(
return { changed, wire };
}
/** Forgets a user's snapshot so the next reconcile reads everything. */
export function invalidateSnapshot(userId: string): void {
snapshots.delete(userId);
}
const listeners = new Map<string, Set<Response>>();
let debounce: NodeJS.Timeout | null = null;
let heartbeat: NodeJS.Timeout | null = null;
-6
View File
@@ -64,12 +64,6 @@ export function locate(
return and(eq(table.syncId, syncId), eq(ownerColumn(entity), userId))!;
}
export function rowSyncId(entity: RegisteredSyncEntity, row: SyncRow): string {
return entity.singleton
? singletonSyncId(entity.type)
: (row.syncId as string);
}
let hashKey: Buffer | null = null;
export async function getHashKey(): Promise<Buffer> {
@@ -10,8 +10,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
*
* The Drizzle schema said so, the repository was written against it, but the
* runtime bootstrap still created `user_id TEXT NOT NULL`. A second, corrected
* `CREATE TABLE IF NOT EXISTS` further down was a no-op — the table already
* existed — so every fresh install got the old constraint and every user
* `CREATE TABLE IF NOT EXISTS` further down was a no-op, the table already
* existed, so every fresh install got the old constraint and every user
* deletion (including the OIDC account-link cleanup) failed once the account
* had logged in at least once.
*/
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* `migrateSchema()` carried a `SELECT id FROM <table> LIMIT 1` probe for a
* number of tables that the primary bootstrap already creates. The probe never
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran — and one
* threw, so the `CREATE TABLE IF NOT EXISTS` in its catch never ran, and one
* of those unreachable copies had drifted away from the real definition
* (`sessions` had lost `ON DELETE CASCADE`).
*
@@ -61,7 +61,7 @@ describe("bootstrap creates the tables the removed probes covered", () => {
});
it("creates them on a database old enough to predate them", async () => {
// A database with users and hosts but none of the tables above — the
// A database with users and hosts but none of the tables above, the
// upgrade path the deleted probes appeared to be protecting.
const seed = new Database(":memory:");
seed.exec(`
@@ -59,7 +59,7 @@ describe("needsExplicitPersist", () => {
/**
* schema.pg.ts and schema.mysql.ts are generated from schema.ts. These check the
* generated output is usable rather than merely syntactically valid — the
* generated output is usable rather than merely syntactically valid, the
* repository layer's correctness rests on all three behaving the same way.
*/
describe("generated schemas", () => {
@@ -133,7 +133,7 @@ describe("generated schemas", () => {
* SQLite builds its indexes at runtime from PERFORMANCE_INDEXES; Postgres and
* MySQL only ever get what the migrations declare, which comes from schema.ts.
* Anything listed in one and missing from the other is an index the engines
* chosen for scale silently do without — which is how 31 of them went missing.
* chosen for scale silently do without, which is how 31 of them went missing.
*/
it("declares every performance index in schema.ts", () => {
// Both are the leading column of an existing composite unique index, which
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* 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` — so every host shared before the upgrade silently
* `NOT NULL DEFAULT 0`, so every host shared before the upgrade silently
* stopped supplying credentials, and recipients hit "No valid authentication
* method provided" on hosts that had worked the day before.
*
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* `ssh_credentials.username` became nullable when key-only credentials landed,
* and databases created before that are rebuilt on startup to drop the
* constraint — SQLite cannot ALTER a column.
* constraint, SQLite cannot ALTER a column.
*
* The rebuild restated the table's columns as a literal, then copied rows with
* `INSERT INTO temp SELECT <every live column>`. The table has gained columns
@@ -17,7 +17,7 @@ describe("rowsAffected", () => {
});
it("reads changes from a better-sqlite3 write result", () => {
// The shape of a write with no .returning() attached — verified against
// The shape of a write with no .returning() attached, verified against
// the driver, not assumed.
expect(rowsAffected({ changes: 1, lastInsertRowid: 7 })).toBe(1);
expect(rowsAffected({ changes: 0, lastInsertRowid: 7 })).toBe(0);
@@ -81,7 +81,7 @@ describe("supportsReturning", () => {
expect(supportsReturning("sqlite")).toBe(true);
expect(supportsReturning("postgres")).toBe(true);
// No RETURNING clause in MySQL, and drizzle's mysql-core does not expose
// the method — call sites that need rows back must read first.
// the method, call sites that need rows back must read first.
expect(supportsReturning("mysql")).toBe(false);
});
});
@@ -11,7 +11,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
* The MySQL path cannot be exercised against a real engine here, and its whole
* correctness is an ordering property: an update must be read AFTER the write,
* a delete BEFORE it. Get either backwards and the rows describe the wrong
* state — silently, with no error anywhere.
* state, silently, with no error anywhere.
*
* So the drizzle handle is stubbed and the order of calls is recorded.
*/
@@ -169,7 +169,7 @@ describe("deleteReturning", () => {
await deleteReturning(context, {} as never, where);
// Without this, a concurrent write between them makes the returned rows
// describe a state that never existed — and with a pool the second
// describe a state that never existed, and with a pool the second
// statement need not even reach the same connection.
expect(calls[0]).toBe("begin");
expect(calls[calls.length - 1]).toBe("commit");
@@ -8,8 +8,8 @@ import {
/**
* The settings cache lives in one process and is updated by whichever process
* wrote the setting. On SQLite that is the only process there is. On Postgres
* and MySQL — the reason those exist here is to let several instances share one
* database — a setting changed on one replica would otherwise never reach the
* and MySQL, the reason those exist here is to let several instances share one
* database, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read cannot go back to the database.
*
* Re-priming on a timer does not make settings immediately consistent. It
@@ -57,7 +57,7 @@ describe("settings cache refresh", () => {
it("keeps running after a refresh throws", async () => {
// A transient database blip must not stop the loop, or the replica is stuck
// on stale settings until it restarts — the exact failure this prevents.
// on stale settings until it restarts, the exact failure this prevents.
const refresh = vi
.fn()
.mockRejectedValueOnce(new Error("connection reset"))
@@ -20,7 +20,7 @@ import type { DatabaseDialect } from "../../../database/db/dialect.js";
*
* Defaults to SQLite, so `npm test` behaves as it always has and needs no
* server. Set TEST_DIALECT=postgres or mysql, plus TEST_DATABASE_URL, to run
* the same tests against a real one — see the database-dialects CI job.
* the same tests against a real one, see the database-dialects CI job.
*/
export function testDialect(env = process.env): DatabaseDialect {
const value = env.TEST_DIALECT?.trim().toLowerCase();
@@ -125,7 +125,7 @@ export class TestSqliteDatabase {
* Portable write for test setup.
*
* Separate from query() because better-sqlite3 refuses `.all()` on a
* statement that returns no rows — "This statement does not return data".
* statement that returns no rows, "This statement does not return data".
*/
async run(statement: SQL): Promise<void> {
const context = this.context;
@@ -207,7 +207,7 @@ async function truncateAll(context: DatabaseContext): Promise<void> {
}
/**
* Splits seed SQL into statements, ignoring semicolons inside string literals —
* Splits seed SQL into statements, ignoring semicolons inside string literals,
* JSON payloads in the fixtures contain them.
*/
function splitStatements(sql: string): string[] {
@@ -274,7 +274,7 @@ function portableSql(statement: string, dialect: DatabaseDialect): string {
if (dialect !== "mysql") return out;
// Only the column list, before VALUES. A blanket replace also mangles the
// double quotes inside JSON payloads in the values — '{"slots":[]}' became
// double quotes inside JSON payloads in the values, '{"slots":[]}' became
// '{`slots`:[]}', which is valid SQL and silently wrong data.
const split = /^(.*?\bVALUES\b)(.*)$/is.exec(out);
if (!split) return out.replace(/"([a-z_]+)"/g, "`$1`");
@@ -354,7 +354,7 @@ async function resyncAutoIncrement(
for (const table of tables) {
// Only tables whose id is generated. A text primary key, like users.id,
// has no sequence and no counter to move, and a table keyed on something
// else entirely — host_sidebar_preferences.user_id — has no id at all.
// else entirely, host_sidebar_preferences.user_id, has no id at all.
if (context.dialect === "postgres") {
// pg_get_serial_sequence() raises 42703 rather than returning null when
// the column is missing, so let information_schema decide whether there
@@ -408,7 +408,7 @@ async function resyncAutoIncrement(
*
* Every test builds a fixture, and each would otherwise re-run the migrator
* against the same shared database. drizzle's journal makes that a no-op only
* when the first run finished — several fixtures racing inside one file hit
* when the first run finished, several fixtures racing inside one file hit
* "table already exists" instead.
*/
const migrations = new Map<string, Promise<void>>();
@@ -436,7 +436,7 @@ let cachedSqliteSchema: string | null = null;
* The full schema, from the generated SQLite migrations rather than
* hand-written DDL in each test file.
*
* Tests used to declare a cut-down version of every table they touched — a
* Tests used to declare a cut-down version of every table they touched, a
* `users` with five columns where the real one has thirty. That drifts from the
* schema silently, and it is the reason the same tests could not be pointed at
* another engine.
@@ -19,7 +19,7 @@ import {
*
* The resolved row supplies the address, the credentials, the jump hosts and
* the stored host key, so the session opened on whichever machine owned that
* id on the server — the host list stayed correct the whole time, and nothing
* id on the server, the host list stayed correct the whole time, and nothing
* announced the substitution.
*/
describe("hostAddressMismatch", () => {
@@ -26,7 +26,7 @@ vi.mock("../../utils/shared-host-auth-resolver.js", () => ({
/**
* A numeric host id belongs to whichever database produced it. Resolving the
* desktop app's id against a sync server's table lands on whatever host owns
* that number there — a different machine, with its own address, credentials
* that number there, a different machine, with its own address, credentials
* and host key. `sync_id` is the same string on both sides.
*/
describe("resolveHostBySyncId", () => {
+24
View File
@@ -0,0 +1,24 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getBundledPluginsDir } from "../../plugins/paths.js";
describe("getBundledPluginsDir", () => {
const saved = process.env.TERMIX_BUNDLED_PLUGINS_DIR;
afterEach(() => {
if (saved === undefined) delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
else process.env.TERMIX_BUNDLED_PLUGINS_DIR = saved;
});
it("reads dist/plugins when run from source", () => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
expect(getBundledPluginsDir()).toBe(
path.resolve(process.cwd(), "dist", "plugins"),
);
});
it("honors the override", () => {
process.env.TERMIX_BUNDLED_PLUGINS_DIR = "/somewhere/else";
expect(getBundledPluginsDir()).toBe("/somewhere/else");
});
});
@@ -123,7 +123,7 @@ describe("forwardAuditEntry", () => {
);
});
it("swallows transport errors — a dead SIEM must not break auditing", async () => {
it("swallows transport errors, a dead SIEM must not break auditing", async () => {
safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED"));
await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false);
@@ -140,7 +140,7 @@ describe("forwardAuditEntry", () => {
await forwardAuditEntry(ENTRY, NOW, env);
}
// 5 per-entry warnings, then one suppression notice — not 8.
// 5 per-entry warnings, then one suppression notice, not 8.
const perEntry = logs.warn.mock.calls.filter(
(call) => call[0] === "Failed to forward audit entry",
);
+1 -1
View File
@@ -22,7 +22,7 @@ function readForwardSetting(key: string): string | null {
/**
* How many consecutive failures before the forwarder stops complaining on every
* entry. It keeps trying — this only throttles the log noise, and it reports
* entry. It keeps trying, this only throttles the log noise, and it reports
* again once delivery recovers.
*/
const QUIET_AFTER_FAILURES = 5;
+2 -2
View File
@@ -6,8 +6,8 @@ import { type Request, type RequestHandler, type Response } from "express";
*
* The default is 1KB; this is slightly higher because almost every response
* under a few KB here is a small status or preference object where the CPU and
* the extra headers are not worth it. The payloads that matter — host lists,
* audit pages, fleet inventories — are orders of magnitude above this.
* the extra headers are not worth it. The payloads that matter, host lists,
* audit pages, fleet inventories, are orders of magnitude above this.
*/
const MIN_RESPONSE_BYTES = 2048;
@@ -47,7 +47,7 @@ export async function runLegacySharedCredentialCleanup(): Promise<{
// Nothing to clean up on an engine this application has never run on. A
// Postgres or MySQL database is created by the drizzle migrations, which have
// never emitted these legacy columns, so there is nothing to drop — and the
// never emitted these legacy columns, so there is nothing to drop, and the
// check itself needs a synchronous PRAGMA that only SQLite offers.
if (!needsExplicitPersist(resolveDatabaseDialect())) return result;
+1 -1
View File
@@ -201,7 +201,7 @@ class DataCrypto {
migratedFieldsCount: number;
}> {
// Only a database that predates field encryption has plaintext to migrate,
// and only SQLite deployments can predate it — Postgres and MySQL support
// and only SQLite deployments can predate it, Postgres and MySQL support
// arrived after. The store also needs synchronous queries no other driver
// has, so this would throw rather than find nothing to do.
if (!needsExplicitPersist(resolveDatabaseDialect())) {
+1 -1
View File
@@ -14,7 +14,7 @@ export class DataDirMisconfiguredError extends Error {
/**
* Directories Termix has shipped or documented as a data location. A deployment
* that loses DATA_DIR — an unloaded .env file, an unmounted volume — falls back
* that loses DATA_DIR, an unloaded .env file, an unmounted volume, falls back
* to the default and finds an empty directory, which is indistinguishable from a
* first run. Checking these tells the two apart.
*/
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* A thrown value is not guaranteed to be an `Error`, libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or responses.
+1 -1
View File
@@ -295,7 +295,7 @@ class PermissionManager {
* queries instead of one call per host.
*
* canAccessHost costs between one and four queries, so filtering a list with
* it is linear in host count — and the status poll does exactly that every
* it is linear in host count, and the status poll does exactly that every
* few seconds for the whole fleet. This answers the same question for many
* hosts at once using the same three rules, in the same order: owner, then
* an unexpired grant, then admin bypass.
+1 -1
View File
@@ -34,7 +34,7 @@ try {
// The pure-JS fallback in ssh2 for chacha20-poly1305@openssh.com is broken and
// corrupts the transport: the target sshd aborts the KEX with
// "ssh_dispatch_run_fatal: ... incomplete message [preauth]" and the client times out.
// A working OpenSSL "chacha20" cipher is NOT sufficient here — only the native
// A working OpenSSL "chacha20" cipher is NOT sufficient here, only the native
// binding (sshcrypto.node) makes chacha20-poly1305 usable. Keep it disabled otherwise
// so filterCiphers() drops it and the connection negotiates AES-GCM instead.
ssh2BindingAvailable = false;
+1 -1
View File
@@ -7,7 +7,7 @@ import { SystemCrypto } from "./system-crypto.js";
* Per-user field encryption (DataCrypto/FieldCrypto) derives its key from the
* user's DEK, which works for host passwords and SSH keys. It does not work for
* SSO provider configuration: `sso_providers` has no `userId`, and the OIDC
* client secret and LDAP bind password must be readable during login — before
* client secret and LDAP bind password must be readable during login, before
* any user is authenticated, let alone unlocked.
*
* Those secrets were previously stored base64-encoded behind an `encoded:`