feat: finish the ai plugin (B18)

This commit is contained in:
LukeGus committed 2026-09-24 13:43:26 -05:00
1 parent 6b4d1432ab
commit 21937a6b02
193 files changed
+23429 -4244

No files matched your search

-109
View File
@@ -876,8 +876,6 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "ai_assistant_enabled", "INTEGER");
addColumnIfNotExists("user_preferences", "ai_read_only_commands", "INTEGER");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
@@ -1089,11 +1087,6 @@ const migrateSchema = () => {
"enable_terminal_toolbar",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_ai_assistant",
"INTEGER NOT NULL DEFAULT 0",
);
addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
addColumnIfNotExists("ssh_data", "domain", "TEXT");
@@ -2185,108 +2178,6 @@ const migrateSchema = () => {
}
// --- sync end ---
// --- ai begin ---
try {
sqlite.prepare("SELECT id FROM ai_providers LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS ai_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider_type TEXT NOT NULL,
label TEXT NOT NULL,
base_url TEXT,
api_key TEXT,
api_key_prefix TEXT,
default_model TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, label)
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create ai_providers table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM ai_conversations LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS ai_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT,
provider_id INTEGER,
model TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create ai_conversations table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM ai_messages LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create ai_messages table", {
operation: "schema_migration",
error: createError,
});
}
}
try {
sqlite.prepare("SELECT id FROM ai_proposals LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS ai_proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
summary TEXT,
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
applied_at TEXT,
result_summary TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
} catch (createError) {
databaseLogger.warn("Failed to create ai_proposals table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- ai end ---
// Audit trails and session recordings used to be deleted along with the user
// they referenced, which defeats the point of keeping them.
migrateAuditRetention(sqlite);
@@ -123,35 +123,6 @@ export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
table: "dismissed_alerts",
columns: "user_id",
},
// AI assistant.
{
name: "idx_ai_providers_user_label",
table: "ai_providers",
columns: "user_id, label",
unique: true,
},
{
name: "idx_ai_conversations_user",
table: "ai_conversations",
columns: "user_id, updated_at",
},
{
name: "idx_ai_messages_conversation",
table: "ai_messages",
columns: "conversation_id, created_at",
},
{
name: "idx_ai_proposals_user",
table: "ai_proposals",
columns: "user_id, status",
},
{
name: "idx_ai_proposals_conversation",
table: "ai_proposals",
columns: "conversation_id",
},
];
interface IndexableDatabase {
+54 -178
View File
@@ -62,10 +62,10 @@ export const ssoProviders = mysqlTable("sso_providers", {
enabled: boolean("enabled").notNull().default(true),
displayOrder: int("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -83,7 +83,7 @@ export const sessions = mysqlTable(
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: int("sso_provider_id"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -108,7 +108,7 @@ export const trustedDevices = mysqlTable(
deviceFingerprint: text("device_fingerprint").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -134,7 +134,7 @@ export const userExternalIdentities = mysqlTable(
/** The provider's id for the user, at most 255 characters. */
subject: varchar("subject", { length: 255 }).notNull(),
email: text("email"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -186,7 +186,7 @@ export const webauthnCredentials = mysqlTable("webauthn_credentials", {
backedUp: boolean("backed_up").notNull().default(false),
transports: text("transports"),
userVerification: text("user_verification").notNull().default("preferred"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
lastUsedAt: text("last_used_at"),
@@ -272,9 +272,6 @@ export const hosts = mysqlTable(
enableTerminalToolbar: boolean("enable_terminal_toolbar")
.notNull()
.default(true),
enableAiAssistant: boolean("enable_ai_assistant")
.notNull()
.default(false),
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
.notNull()
.default(true),
@@ -351,10 +348,10 @@ export const hosts = mysqlTable(
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -420,10 +417,10 @@ export const sshCredentials = mysqlTable(
usageCount: int("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -470,10 +467,10 @@ export const sshFolders = mysqlTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: int("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -525,7 +522,7 @@ export const hostAccess = mysqlTable(
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
lastAccessedAt: text("last_accessed_at"),
@@ -555,10 +552,10 @@ export const sharedHostAuthOverrides = mysqlTable(
credentialId: int("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -600,10 +597,10 @@ export const sharedHostSecrets = mysqlTable(
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -631,10 +628,10 @@ export const roles = mysqlTable("roles", {
permissions: text("permissions"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -725,7 +722,7 @@ export const opksshTokens = mysqlTable(
issuer: text("issuer"),
audience: text("audience"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -765,10 +762,10 @@ export const vaultProfiles = mysqlTable("vault_profiles", {
// When true the profile is visible/usable by all users on the server
shared: boolean("shared").notNull().default(false),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -790,7 +787,7 @@ export const vaultTokens = mysqlTable(
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -812,7 +809,7 @@ export const apiKeys = mysqlTable(
name: varchar("name", { length: 255 }).notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: varchar("created_at", { length: 255 }).notNull().default(sql`(CURRENT_TIMESTAMP)`),
createdAt: text("created_at").notNull().default(sql`(CURRENT_TIMESTAMP)`),
expiresAt: varchar("expires_at", { length: 255 }),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
@@ -831,13 +828,13 @@ export const userOpenTabs = mysqlTable(
hostId: int("host_id").references(() => hosts.id, {
onDelete: "cascade",
}),
label: varchar("label", { length: 255 }).notNull(),
label: text("label").notNull(),
tabOrder: int("tab_order").notNull().default(0),
backendSessionId: text("backend_session_id"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -868,19 +865,13 @@ export const userPreferences = mysqlTable("user_preferences", {
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
// null means the user has not been asked yet; the assistant stays hidden
// until this is explicitly true and the admin global is on.
aiAssistantEnabled: boolean("ai_assistant_enabled"),
// Opt-in to letting the assistant run allowlisted read-only diagnostics
// without a per-command approval click.
aiReadOnlyCommands: boolean("ai_read_only_commands"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -892,7 +883,7 @@ export const hostSidebarPreferences = mysqlTable("host_sidebar_preferences", {
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -906,7 +897,7 @@ export const credentialSidebarPreferences = mysqlTable(
// JSON-encoded CredentialSidebarPreferences. No secrets in this blob,
// same convention as hostSidebarPreferences.data.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -920,7 +911,7 @@ export const uiPreferences = mysqlTable("ui_preferences", {
// state). No secrets in this blob, same convention as
// hostSidebarPreferences.data.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -930,14 +921,14 @@ export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: varchar("label", { length: 255 }).notNull(),
label: text("label").notNull(),
url: text("url").notNull(),
order: int("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -955,10 +946,10 @@ export const termixIdentities = mysqlTable("termix_identities", {
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -978,7 +969,7 @@ export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: varchar("label", { length: 255 }),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
@@ -986,7 +977,7 @@ export const termixIdentityKeys = mysqlTable("termix_identity_keys", {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -1006,10 +997,10 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: int("validity_days").notNull().default(90),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -1025,7 +1016,7 @@ export const notificationChannels = mysqlTable("notification_channels", {
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -1045,10 +1036,10 @@ export const homepageItems = mysqlTable(
config: text("config").notNull().default("{}"),
folderId: int("folder_id"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1063,7 +1054,7 @@ export const homepageLayouts = mysqlTable("homepage_layouts", {
.references(() => users.id, { onDelete: "cascade" }),
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
layout: text("layout").notNull().default("{}"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
});
@@ -1088,121 +1079,6 @@ export const syncTombstones = mysqlTable("sync_tombstones", {
});
// --- sync end ---
// --- ai begin ---
/**
* A user's connection to one AI provider. api_key is encrypted at rest via
* FieldCrypto; it is never returned to the frontend, which only ever sees
* api_key_prefix for display.
*/
export const aiProviders = mysqlTable(
"ai_providers",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// ollama | anthropic | openai | gemini | openai_compatible
providerType: text("provider_type").notNull(),
label: varchar("label", { length: 255 }).notNull(),
// Required for ollama and openai_compatible, optional elsewhere.
baseUrl: text("base_url"),
apiKey: text("api_key"),
// First few characters, kept in the clear so the UI can identify a key.
apiKeyPrefix: text("api_key_prefix"),
defaultModel: text("default_model"),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
],
);
export const aiConversations = mysqlTable(
"ai_conversations",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title"),
providerId: int("provider_id"),
model: text("model"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
],
);
export const aiMessages = mysqlTable(
"ai_messages",
{
id: int("id").autoincrement().primaryKey(),
conversationId: int("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
// user | assistant | tool
role: text("role").notNull(),
content: text("content").notNull().default(""),
// Serialized tool calls and their results for this turn.
toolCalls: text("tool_calls"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_ai_messages_conversation").on(
table.conversationId,
table.createdAt,
),
],
);
/**
* A change the assistant wants to make. Nothing here has been applied: the
* payload is re-validated against the tool schema at apply time and only then
* dispatched through the same repository logic a human action uses.
*/
export const aiProposals = mysqlTable(
"ai_proposals",
{
id: int("id").autoincrement().primaryKey(),
conversationId: int("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The propose_* tool name that produced this.
kind: text("kind").notNull(),
summary: text("summary"),
payload: text("payload").notNull().default("{}"),
// pending | applied | rejected | expired
status: varchar("status", { length: 255 }).notNull().default("pending"),
appliedAt: text("applied_at"),
resultSummary: text("result_summary"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index("idx_ai_proposals_user").on(table.userId, table.status),
index("idx_ai_proposals_conversation").on(table.conversationId),
],
);
// --- ai end ---
// --- collab rooms ---
// --- secret sources ---
@@ -1227,10 +1103,10 @@ export const secretSources = mysqlTable(
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1265,7 +1141,7 @@ export const credentialAccess = mysqlTable(
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1303,10 +1179,10 @@ export const sharedCredentialSecrets = mysqlTable(
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1355,7 +1231,7 @@ export const folderAccess = mysqlTable(
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1388,7 +1264,7 @@ export const plugins = mysqlTable(
installedAt: text("installed_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
autoUpdate: boolean("auto_update")
@@ -1456,7 +1332,7 @@ export const pluginInstallCounts = mysqlTable(
registryId: varchar("registry_id", { length: 255 }).notNull(),
count: int("count").notNull().default(0),
source: text("source").notNull().default("aggregate-telemetry"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1482,7 +1358,7 @@ export const pluginStorage = mysqlTable(
.references(() => plugins.id, { onDelete: "cascade" }),
key: varchar("key", { length: 255 }).notNull(),
value: text("value").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
@@ -1519,7 +1395,7 @@ export const pluginSettings = mysqlTable(
encrypted: boolean("encrypted")
.notNull()
.default(false),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
+54 -178
View File
@@ -63,10 +63,10 @@ export const ssoProviders = pgTable("sso_providers", {
enabled: boolean("enabled").notNull().default(true),
displayOrder: integer("display_order").notNull().default(0),
config: text("config").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -84,7 +84,7 @@ export const sessions = pgTable(
oidcSub: text("oidc_sub"),
oidcSid: text("oidc_sid"),
ssoProviderId: integer("sso_provider_id"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -109,7 +109,7 @@ export const trustedDevices = pgTable(
deviceFingerprint: text("device_fingerprint").notNull(),
deviceType: text("device_type").notNull(),
deviceInfo: text("device_info").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -135,7 +135,7 @@ export const userExternalIdentities = pgTable(
/** The provider's id for the user, at most 255 characters. */
subject: varchar("subject", { length: 255 }).notNull(),
email: text("email"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -187,7 +187,7 @@ export const webauthnCredentials = pgTable("webauthn_credentials", {
backedUp: boolean("backed_up").notNull().default(false),
transports: text("transports"),
userVerification: text("user_verification").notNull().default("preferred"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastUsedAt: text("last_used_at"),
@@ -273,9 +273,6 @@ export const hosts = pgTable(
enableTerminalToolbar: boolean("enable_terminal_toolbar")
.notNull()
.default(true),
enableAiAssistant: boolean("enable_ai_assistant")
.notNull()
.default(false),
showTerminalInSidebar: boolean("show_terminal_in_sidebar")
.notNull()
.default(true),
@@ -352,10 +349,10 @@ export const hosts = pgTable(
// sync -- local autoincrement ids collide across instances.
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -421,10 +418,10 @@ export const sshCredentials = pgTable(
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -471,10 +468,10 @@ export const sshFolders = pgTable(
// to name sort, same convention as hosts.sortOrder.
sortOrder: integer("sort_order"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -526,7 +523,7 @@ export const hostAccess = pgTable(
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
lastAccessedAt: text("last_accessed_at"),
@@ -556,10 +553,10 @@ export const sharedHostAuthOverrides = pgTable(
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id, { onDelete: "cascade" }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -601,10 +598,10 @@ export const sharedHostSecrets = pgTable(
encryptedKeyType: text("encrypted_key_type"),
encryptedDomain: text("encrypted_domain"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -632,10 +629,10 @@ export const roles = pgTable("roles", {
permissions: text("permissions"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -726,7 +723,7 @@ export const opksshTokens = pgTable(
issuer: text("issuer"),
audience: text("audience"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -766,10 +763,10 @@ export const vaultProfiles = pgTable("vault_profiles", {
// When true the profile is visible/usable by all users on the server
shared: boolean("shared").notNull().default(false),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -791,7 +788,7 @@ export const vaultTokens = pgTable(
sshCert: text("ssh_cert").notNull(),
privateKey: text("private_key").notNull(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }).notNull(),
@@ -813,7 +810,7 @@ export const apiKeys = pgTable(
name: varchar("name", { length: 255 }).notNull(),
tokenHash: text("token_hash").notNull(),
tokenPrefix: text("token_prefix").notNull(),
createdAt: varchar("created_at", { length: 255 }).notNull().default(sql`CURRENT_TIMESTAMP`),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: varchar("expires_at", { length: 255 }),
lastUsedAt: text("last_used_at"),
isActive: boolean("is_active").notNull().default(true),
@@ -832,13 +829,13 @@ export const userOpenTabs = pgTable(
hostId: integer("host_id").references(() => hosts.id, {
onDelete: "cascade",
}),
label: varchar("label", { length: 255 }).notNull(),
label: text("label").notNull(),
tabOrder: integer("tab_order").notNull().default(0),
backendSessionId: text("backend_session_id"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -869,19 +866,13 @@ export const userPreferences = pgTable("user_preferences", {
disableUpdateCheck: boolean("disable_update_check"),
confirmTabClose: boolean("confirm_tab_close"),
hiddenRailTabs: text("hidden_rail_tabs"),
// null means the user has not been asked yet; the assistant stays hidden
// until this is explicitly true and the admin global is on.
aiAssistantEnabled: boolean("ai_assistant_enabled"),
// Opt-in to letting the assistant run allowlisted read-only diagnostics
// without a per-command approval click.
aiReadOnlyCommands: boolean("ai_read_only_commands"),
compactHostView: boolean("compact_host_view"),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
customKeybindings: text("custom_keybindings"),
terminalDefaults: text("terminal_defaults"),
terminalMacros: text("terminal_macros"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -893,7 +884,7 @@ export const hostSidebarPreferences = pgTable("host_sidebar_preferences", {
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -907,7 +898,7 @@ export const credentialSidebarPreferences = pgTable(
// JSON-encoded CredentialSidebarPreferences. No secrets in this blob,
// same convention as hostSidebarPreferences.data.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -921,7 +912,7 @@ export const uiPreferences = pgTable("ui_preferences", {
// state). No secrets in this blob, same convention as
// hostSidebarPreferences.data.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -931,14 +922,14 @@ export const dashboardServiceLinks = pgTable("dashboard_service_links", {
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
label: varchar("label", { length: 255 }).notNull(),
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -956,10 +947,10 @@ export const termixIdentities = pgTable("termix_identities", {
.references(() => users.id, { onDelete: "cascade" }),
handle: varchar("handle", { length: 255 }).notNull().unique(),
description: text("description"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -979,7 +970,7 @@ export const termixIdentityKeys = pgTable("termix_identity_keys", {
// the /<ALGO> resolver filter (RSA / ED25519 / ECDSA / ...).
keyType: text("key_type").notNull(),
algorithm: text("algorithm").notNull(),
label: varchar("label", { length: 255 }),
label: text("label"),
comment: text("comment"),
// "manual" (pasted) or "credential" (imported from an ssh_credentials entry).
source: text("source").notNull().default("manual"),
@@ -987,7 +978,7 @@ export const termixIdentityKeys = pgTable("termix_identity_keys", {
onDelete: "set null",
}),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -1007,10 +998,10 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
validityDays: integer("validity_days").notNull().default(90),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -1026,7 +1017,7 @@ export const notificationChannels = pgTable("notification_channels", {
type: text("type").notNull(),
config: text("config").notNull(),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -1046,10 +1037,10 @@ export const homepageItems = pgTable(
config: text("config").notNull().default("{}"),
folderId: integer("folder_id"),
syncId: varchar("sync_id", { length: 255 }).unique(),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1064,7 +1055,7 @@ export const homepageLayouts = pgTable("homepage_layouts", {
.references(() => users.id, { onDelete: "cascade" }),
// JSON: { entries: HomepageLayoutEntry[], pan: {x,y}, zoom: number }
layout: text("layout").notNull().default("{}"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
@@ -1089,121 +1080,6 @@ export const syncTombstones = pgTable("sync_tombstones", {
});
// --- sync end ---
// --- ai begin ---
/**
* A user's connection to one AI provider. api_key is encrypted at rest via
* FieldCrypto; it is never returned to the frontend, which only ever sees
* api_key_prefix for display.
*/
export const aiProviders = pgTable(
"ai_providers",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// ollama | anthropic | openai | gemini | openai_compatible
providerType: text("provider_type").notNull(),
label: varchar("label", { length: 255 }).notNull(),
// Required for ollama and openai_compatible, optional elsewhere.
baseUrl: text("base_url"),
apiKey: text("api_key"),
// First few characters, kept in the clear so the UI can identify a key.
apiKeyPrefix: text("api_key_prefix"),
defaultModel: text("default_model"),
enabled: boolean("enabled").notNull().default(true),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
],
);
export const aiConversations = pgTable(
"ai_conversations",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title"),
providerId: integer("provider_id"),
model: text("model"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
],
);
export const aiMessages = pgTable(
"ai_messages",
{
id: serial("id").primaryKey(),
conversationId: integer("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
// user | assistant | tool
role: text("role").notNull(),
content: text("content").notNull().default(""),
// Serialized tool calls and their results for this turn.
toolCalls: text("tool_calls"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_messages_conversation").on(
table.conversationId,
table.createdAt,
),
],
);
/**
* A change the assistant wants to make. Nothing here has been applied: the
* payload is re-validated against the tool schema at apply time and only then
* dispatched through the same repository logic a human action uses.
*/
export const aiProposals = pgTable(
"ai_proposals",
{
id: serial("id").primaryKey(),
conversationId: integer("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The propose_* tool name that produced this.
kind: text("kind").notNull(),
summary: text("summary"),
payload: text("payload").notNull().default("{}"),
// pending | applied | rejected | expired
status: varchar("status", { length: 255 }).notNull().default("pending"),
appliedAt: text("applied_at"),
resultSummary: text("result_summary"),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_proposals_user").on(table.userId, table.status),
index("idx_ai_proposals_conversation").on(table.conversationId),
],
);
// --- ai end ---
// --- collab rooms ---
// --- secret sources ---
@@ -1228,10 +1104,10 @@ export const secretSources = pgTable(
token: text("token").notNull(),
// Visible to every user; secrets still decrypt with the owner's key.
shared: boolean("shared").notNull().default(false),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1266,7 +1142,7 @@ export const credentialAccess = pgTable(
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1304,10 +1180,10 @@ export const sharedCredentialSecrets = pgTable(
publicKey: text("public_key"),
certPublicKey: text("cert_public_key"),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1356,7 +1232,7 @@ export const folderAccess = pgTable(
permissionLevel: text("permission_level").notNull().default("connect"),
expiresAt: varchar("expires_at", { length: 255 }),
createdAt: varchar("created_at", { length: 255 })
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1389,7 +1265,7 @@ export const plugins = pgTable(
installedAt: text("installed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
autoUpdate: boolean("auto_update")
@@ -1457,7 +1333,7 @@ export const pluginInstallCounts = pgTable(
registryId: varchar("registry_id", { length: 255 }).notNull(),
count: integer("count").notNull().default(0),
source: text("source").notNull().default("aggregate-telemetry"),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1483,7 +1359,7 @@ export const pluginStorage = pgTable(
.references(() => plugins.id, { onDelete: "cascade" }),
key: varchar("key", { length: 255 }).notNull(),
value: text("value").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
@@ -1520,7 +1396,7 @@ export const pluginSettings = pgTable(
encrypted: boolean("encrypted")
.notNull()
.default(false),
updatedAt: varchar("updated_at", { length: 255 })
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
-124
View File
@@ -265,9 +265,6 @@ export const hosts = sqliteTable(
enableTerminalToolbar: integer("enable_terminal_toolbar", { mode: "boolean" })
.notNull()
.default(true),
enableAiAssistant: integer("enable_ai_assistant", { mode: "boolean" })
.notNull()
.default(false),
showTerminalInSidebar: integer("show_terminal_in_sidebar", { mode: "boolean" })
.notNull()
.default(true),
@@ -865,12 +862,6 @@ export const userPreferences = sqliteTable("user_preferences", {
disableUpdateCheck: integer("disable_update_check", { mode: "boolean" }),
confirmTabClose: integer("confirm_tab_close", { mode: "boolean" }),
hiddenRailTabs: text("hidden_rail_tabs"),
// null means the user has not been asked yet; the assistant stays hidden
// until this is explicitly true and the admin global is on.
aiAssistantEnabled: integer("ai_assistant_enabled", { mode: "boolean" }),
// Opt-in to letting the assistant run allowlisted read-only diagnostics
// without a per-command approval click.
aiReadOnlyCommands: integer("ai_read_only_commands", { mode: "boolean" }),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
customThemes: text("custom_themes"),
@@ -1085,121 +1076,6 @@ export const syncTombstones = sqliteTable("sync_tombstones", {
});
// --- sync end ---
// --- ai begin ---
/**
* A user's connection to one AI provider. api_key is encrypted at rest via
* FieldCrypto; it is never returned to the frontend, which only ever sees
* api_key_prefix for display.
*/
export const aiProviders = sqliteTable(
"ai_providers",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// ollama | anthropic | openai | gemini | openai_compatible
providerType: text("provider_type").notNull(),
label: text("label").notNull(),
// Required for ollama and openai_compatible, optional elsewhere.
baseUrl: text("base_url"),
apiKey: text("api_key", { length: 8192 }),
// First few characters, kept in the clear so the UI can identify a key.
apiKeyPrefix: text("api_key_prefix"),
defaultModel: text("default_model"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("idx_ai_providers_user_label").on(table.userId, table.label),
],
);
export const aiConversations = sqliteTable(
"ai_conversations",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
title: text("title"),
providerId: integer("provider_id"),
model: text("model"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_conversations_user").on(table.userId, table.updatedAt),
],
);
export const aiMessages = sqliteTable(
"ai_messages",
{
id: integer("id").primaryKey({ autoIncrement: true }),
conversationId: integer("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
// user | assistant | tool
role: text("role").notNull(),
content: text("content").notNull().default(""),
// Serialized tool calls and their results for this turn.
toolCalls: text("tool_calls"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_messages_conversation").on(
table.conversationId,
table.createdAt,
),
],
);
/**
* A change the assistant wants to make. Nothing here has been applied: the
* payload is re-validated against the tool schema at apply time and only then
* dispatched through the same repository logic a human action uses.
*/
export const aiProposals = sqliteTable(
"ai_proposals",
{
id: integer("id").primaryKey({ autoIncrement: true }),
conversationId: integer("conversation_id")
.notNull()
.references(() => aiConversations.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
// The propose_* tool name that produced this.
kind: text("kind").notNull(),
summary: text("summary"),
payload: text("payload").notNull().default("{}"),
// pending | applied | rejected | expired
status: text("status").notNull().default("pending"),
appliedAt: text("applied_at"),
resultSummary: text("result_summary"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("idx_ai_proposals_user").on(table.userId, table.status),
index("idx_ai_proposals_conversation").on(table.conversationId),
],
);
// --- ai end ---
// --- collab rooms ---
// --- secret sources ---
@@ -1,407 +0,0 @@
import { and, desc, eq } from "drizzle-orm";
import {
aiConversations,
aiMessages,
aiProposals,
aiProviders,
} from "../db/schema.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning } from "./returning.js";
import { formatSqlTimestamp } from "./sql-timestamp.js";
const now = (): string => formatSqlTimestamp(new Date());
export type AiProviderRecord = typeof aiProviders.$inferSelect;
export type AiConversationRecord = typeof aiConversations.$inferSelect;
export type AiMessageRecord = typeof aiMessages.$inferSelect;
export type AiProposalRecord = typeof aiProposals.$inferSelect;
export interface AiProviderInput {
userId: string;
providerType: string;
label: string;
baseUrl?: string | null;
apiKey?: string | null;
defaultModel?: string | null;
enabled?: boolean;
}
/**
* Keeps the first few characters so the UI can tell two keys apart without
* ever receiving the key itself.
*/
export function apiKeyPrefix(apiKey: string | null | undefined): string | null {
if (!apiKey) return null;
return apiKey.slice(0, 6);
}
export class AiRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
/**
* Provider API keys are field-encrypted like any other credential. The key is
* derived from the row id, which does not exist until after the insert, so a
* new provider is written once and then re-encrypted in place with its real
* id -- the same approach NotificationChannelRepository uses for channel configs.
*/
private userDataKey(userId: string): Buffer | null {
try {
return DataCrypto.getUserDataKey(userId);
} catch {
// Crypto is not initialized (tests, early boot); leave the value as is.
return null;
}
}
private encryptApiKey(
apiKey: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return apiKey;
return DataCrypto.encryptRecord(
"ai_providers",
{ id: recordId, apiKey },
userId,
userDataKey,
).apiKey;
}
private decryptApiKey(
apiKey: string,
userId: string,
recordId: number | string,
): string {
const userDataKey = this.userDataKey(userId);
if (!userDataKey) return apiKey;
try {
return DataCrypto.decryptRecord(
"ai_providers",
{ id: recordId, apiKey },
userId,
userDataKey,
).apiKey;
} catch {
// Rows written before encryption was enabled are still plaintext.
return apiKey;
}
}
// --- providers ---
/** Never includes the key material; callers get the masked prefix only. */
async listProviders(userId: string): Promise<AiProviderRecord[]> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(eq(aiProviders.userId, userId))
.orderBy(aiProviders.id);
return rows.map((row) => ({ ...row, apiKey: null }));
}
async findProvider(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
.limit(1);
if (!rows[0]) return null;
return { ...rows[0], apiKey: null };
}
/**
* The one path that returns usable key material. Only the provider adapters
* call this, immediately before an outbound request.
*/
async findProviderWithSecret(
id: number,
userId: string,
): Promise<AiProviderRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)))
.limit(1);
const row = rows[0];
if (!row) return null;
if (!row.apiKey) return row;
return {
...row,
apiKey: this.decryptApiKey(row.apiKey, userId, row.id),
};
}
async createProvider(input: AiProviderInput): Promise<AiProviderRecord> {
const [created] = await insertReturning(this.context, aiProviders, {
userId: input.userId,
providerType: input.providerType,
label: input.label,
baseUrl: input.baseUrl ?? null,
apiKey: input.apiKey ?? null,
apiKeyPrefix: apiKeyPrefix(input.apiKey),
defaultModel: input.defaultModel ?? null,
enabled: input.enabled ?? true,
});
if (input.apiKey) {
const encrypted = this.encryptApiKey(
input.apiKey,
input.userId,
created.id,
);
if (encrypted !== input.apiKey) {
await this.context.drizzle
.update(aiProviders)
.set({ apiKey: encrypted })
.where(eq(aiProviders.id, created.id));
}
}
await this.afterWrite();
return { ...created, apiKey: null };
}
async updateProvider(
id: number,
userId: string,
input: Partial<Omit<AiProviderInput, "userId">>,
): Promise<AiProviderRecord | null> {
const existing = await this.findProvider(id, userId);
if (!existing) return null;
const updates: Record<string, unknown> = { updatedAt: now() };
if (input.providerType !== undefined)
updates.providerType = input.providerType;
if (input.label !== undefined) updates.label = input.label;
if (input.baseUrl !== undefined) updates.baseUrl = input.baseUrl;
if (input.defaultModel !== undefined)
updates.defaultModel = input.defaultModel;
if (input.enabled !== undefined) updates.enabled = input.enabled;
// An empty string clears the key; undefined leaves it untouched.
if (input.apiKey !== undefined) {
if (input.apiKey) {
updates.apiKey = this.encryptApiKey(input.apiKey, userId, id);
updates.apiKeyPrefix = apiKeyPrefix(input.apiKey);
} else {
updates.apiKey = null;
updates.apiKeyPrefix = null;
}
}
await this.context.drizzle
.update(aiProviders)
.set(updates)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
await this.afterWrite();
return this.findProvider(id, userId);
}
async deleteProvider(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(aiProviders)
.where(and(eq(aiProviders.id, id), eq(aiProviders.userId, userId)));
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
// --- conversations ---
async listConversations(
userId: string,
limit = 50,
): Promise<AiConversationRecord[]> {
return this.context.drizzle
.select()
.from(aiConversations)
.where(eq(aiConversations.userId, userId))
.orderBy(desc(aiConversations.updatedAt))
.limit(limit);
}
async findConversation(
id: number,
userId: string,
): Promise<AiConversationRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiConversations)
.where(
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
)
.limit(1);
return rows[0] ?? null;
}
async createConversation(input: {
userId: string;
title?: string | null;
providerId?: number | null;
model?: string | null;
}): Promise<AiConversationRecord> {
const [created] = await insertReturning(this.context, aiConversations, {
userId: input.userId,
title: input.title ?? null,
providerId: input.providerId ?? null,
model: input.model ?? null,
});
await this.afterWrite();
return created;
}
async touchConversation(id: number, title?: string | null): Promise<void> {
const updates: Record<string, unknown> = { updatedAt: now() };
if (title) updates.title = title;
await this.context.drizzle
.update(aiConversations)
.set(updates)
.where(eq(aiConversations.id, id));
await this.afterWrite();
}
async deleteConversation(id: number, userId: string): Promise<boolean> {
const result = await this.context.drizzle
.delete(aiConversations)
.where(
and(eq(aiConversations.id, id), eq(aiConversations.userId, userId)),
);
const deleted = rowsAffected(result) > 0;
if (deleted) await this.afterWrite();
return deleted;
}
// --- messages ---
async listMessages(conversationId: number): Promise<AiMessageRecord[]> {
return this.context.drizzle
.select()
.from(aiMessages)
.where(eq(aiMessages.conversationId, conversationId))
.orderBy(aiMessages.id);
}
async appendMessage(input: {
conversationId: number;
role: string;
content: string;
toolCalls?: string | null;
}): Promise<AiMessageRecord> {
const [created] = await insertReturning(this.context, aiMessages, {
conversationId: input.conversationId,
role: input.role,
content: input.content,
toolCalls: input.toolCalls ?? null,
});
await this.afterWrite();
return created;
}
// --- proposals ---
async listProposals(
userId: string,
conversationId?: number,
): Promise<AiProposalRecord[]> {
const where = conversationId
? and(
eq(aiProposals.userId, userId),
eq(aiProposals.conversationId, conversationId),
)
: eq(aiProposals.userId, userId);
return this.context.drizzle
.select()
.from(aiProposals)
.where(where)
.orderBy(desc(aiProposals.id));
}
async findProposal(
id: number,
userId: string,
): Promise<AiProposalRecord | null> {
const rows = await this.context.drizzle
.select()
.from(aiProposals)
.where(and(eq(aiProposals.id, id), eq(aiProposals.userId, userId)))
.limit(1);
return rows[0] ?? null;
}
async createProposal(input: {
conversationId: number;
userId: string;
kind: string;
summary?: string | null;
payload: string;
}): Promise<AiProposalRecord> {
const [created] = await insertReturning(this.context, aiProposals, {
conversationId: input.conversationId,
userId: input.userId,
kind: input.kind,
summary: input.summary ?? null,
payload: input.payload,
status: "pending",
});
await this.afterWrite();
return created;
}
async setProposalStatus(
id: number,
userId: string,
status: "applied" | "rejected" | "expired",
resultSummary?: string | null,
): Promise<boolean> {
const result = await this.context.drizzle
.update(aiProposals)
.set({
status,
appliedAt: status === "applied" ? now() : null,
resultSummary: resultSummary ?? null,
})
.where(
and(
eq(aiProposals.id, id),
eq(aiProposals.userId, userId),
eq(aiProposals.status, "pending"),
),
);
const updated = rowsAffected(result) > 0;
if (updated) await this.afterWrite();
return updated;
}
// --- account deletion ---
async deleteByUserId(userId: string): Promise<void> {
// ai_messages and ai_proposals cascade from ai_conversations.
await this.context.drizzle
.delete(aiConversations)
.where(eq(aiConversations.userId, userId));
await this.context.drizzle
.delete(aiProviders)
.where(eq(aiProviders.userId, userId));
await this.afterWrite();
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -4,7 +4,6 @@ import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { WebauthnCredentialRepository } from "./webauthn-credential-repository.js";
import { AiRepository } from "./ai-repository.js";
import { NotificationChannelRepository } from "./notification-channel-repository.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
@@ -140,13 +139,6 @@ export function createCurrentWebauthnCredentialRepository(): WebauthnCredentialR
);
}
export function createCurrentAiRepository(): AiRepository {
return new AiRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("ai_repository_write"),
);
}
export function createCurrentNotificationChannelRepository(): NotificationChannelRepository {
return new NotificationChannelRepository(
createCurrentRepositoryContext(),
@@ -6,7 +6,11 @@ import { insertReturning, updateReturning } from "./returning.js";
export type PluginSettingsRecord = typeof pluginSettings.$inferSelect;
export type PluginSettingsScope = "admin" | "user" | "host";
/**
* "secret" holds ctx.secrets values, one row per user and key. The settings
* service is driven by manifest fields, so it never reads them.
*/
export type PluginSettingsScope = "admin" | "user" | "host" | "secret";
/**
* Values behind ctx.settings and the /plugins/:id/settings routes.
@@ -205,7 +205,10 @@ export class UserRepository {
await this.context.drizzle
.delete(pluginSettings)
.where(
and(eq(pluginSettings.scope, "user"), eq(pluginSettings.scopeId, id)),
and(
inArray(pluginSettings.scope, ["user", "secret"]),
eq(pluginSettings.scopeId, id),
),
);
const result = await this.context.drizzle
@@ -1,6 +1,5 @@
import { authLogger } from "../../utils/logger.js";
import {
createCurrentAiRepository,
createCurrentNotificationChannelRepository,
createCurrentApiKeyRepository,
createCurrentAuditLogRepository,
@@ -73,7 +72,6 @@ export async function deleteUserAndRelatedData(
await createCurrentTrustedDeviceRepository().deleteByUserId(userId);
await createCurrentRoleRepository().removeAllRolesFromUser(userId);
await createCurrentAiRepository().deleteByUserId(userId);
await createCurrentNotificationChannelRepository().deleteByUserId(userId);
await createCurrentAuditLogRepository().anonymizeByUserId(userId);
@@ -288,8 +288,6 @@ export function registerHostBulkRoutes(
simpleUpdates.enableTmuxMonitor = updates.enableTmuxMonitor;
if (typeof updates.enableTerminalToolbar === "boolean")
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.enableAiAssistant === "boolean")
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
@@ -751,7 +749,6 @@ export function registerHostBulkRoutes(
enableFileManager: hostData.enableFileManager !== false,
enableTmuxMonitor: hostData.enableTmuxMonitor || false,
enableTerminalToolbar: hostData.enableTerminalToolbar !== false,
enableAiAssistant: hostData.enableAiAssistant || false,
enableCommandHistory: hostData.enableCommandHistory !== false,
showTerminalInSidebar: hostData.showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: hostData.showFileManagerInSidebar ? 1 : 0,
@@ -1012,7 +1009,6 @@ export function registerHostBulkRoutes(
enableFileManager: true,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
showTerminalInSidebar: 0,
showFileManagerInSidebar: 0,
showTunnelInSidebar: 0,
@@ -178,7 +178,6 @@ export type NormalizedImportedHost = Record<string, unknown> & {
enableProxmox?: unknown;
enableTmuxMonitor?: unknown;
enableTerminalToolbar?: unknown;
enableAiAssistant?: unknown;
enableCommandHistory?: unknown;
showTerminalInSidebar?: unknown;
showFileManagerInSidebar?: unknown;
@@ -340,7 +339,6 @@ const CONNECT_LEVEL_FIELDS = new Set([
"enableFileManager",
"enableTmuxMonitor",
"enableTerminalToolbar",
"enableAiAssistant",
"showTerminalInSidebar",
"showFileManagerInSidebar",
"showTunnelInSidebar",
@@ -436,7 +434,6 @@ export function transformHostResponse(
enableFileManager: host.enableFileManager !== false,
enableTmuxMonitor: !!host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar !== false,
enableAiAssistant: !!host.enableAiAssistant,
showTerminalInSidebar: !!host.showTerminalInSidebar,
showFileManagerInSidebar: !!host.showFileManagerInSidebar,
showTunnelInSidebar: !!host.showTunnelInSidebar,
+4 -62
View File
@@ -15,17 +15,15 @@ import {
pickResolvedUsername,
} from "../../hosts/credential-username.js";
import { emitInternalEvent } from "../../hosts/internal-events.js";
import { deleteOwnedHost } from "../../hosts/delete-host.js";
import {
createCurrentCredentialRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
createCurrentSyncTombstoneRepository,
createCurrentSharedHostAuthOverrideRepository,
} from "../repositories/factory.js";
import {
@@ -202,7 +200,6 @@ router.post(
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
enableAiAssistant,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -332,7 +329,6 @@ router.post(
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -756,7 +752,6 @@ router.post(
enableWebUi: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,
@@ -886,7 +881,6 @@ router.put(
enableProxmox,
enableTmuxMonitor,
enableTerminalToolbar,
enableAiAssistant,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -1017,7 +1011,6 @@ router.put(
scpLegacy: scpLegacy ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1,
enableAiAssistant: enableAiAssistant ? 1 : 0,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -2084,7 +2077,6 @@ router.get(
enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats,
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false,
enableAiAssistant: !!resolvedHost.enableAiAssistant,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -2241,7 +2233,6 @@ router.get(
enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor,
enableTerminalToolbar:
resolvedHost.enableTerminalToolbar !== false,
enableAiAssistant: !!resolvedHost.enableAiAssistant,
showTerminalInSidebar: !!resolvedHost.showTerminalInSidebar,
showFileManagerInSidebar: !!resolvedHost.showFileManagerInSidebar,
showTunnelInSidebar: !!resolvedHost.showTunnelInSidebar,
@@ -2418,13 +2409,9 @@ router.delete(
hostId: parseInt(hostId),
});
try {
const hostToDelete =
await createCurrentHostResolutionRepository().findHostByIdForUser(
Number(hostId),
userId,
);
const deleted = await deleteOwnedHost(userId, Number(hostId));
if (!hostToDelete) {
if (!deleted) {
sshLogger.warn("SSH host not found for deletion", {
operation: "host_delete",
hostId: parseInt(hostId),
@@ -2433,34 +2420,6 @@ router.delete(
return res.status(404).json({ error: "SSH host not found" });
}
const numericHostId = Number(hostId);
// file manager recent/pinned/shortcuts, transfer_recent, command
// history and session recordings cascade on the host's refHost()
// foreign key, as the file-manager, ssh-terminal and
// session-recording plugins' adopted tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId(
numericHostId,
);
await createCurrentRecentActivityRepository().deleteByHostId(
numericHostId,
);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(
numericHostId,
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
if (hostToDelete.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"hosts",
hostToDelete.syncId,
);
}
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
userId,
@@ -2474,29 +2433,12 @@ router.delete(
action: "delete_host",
resourceType: "host",
resourceId: hostId,
resourceName: hostToDelete.name ?? hostToDelete.ip,
resourceName: deleted.name,
ipAddress: dhIp,
userAgent: dhUa,
success: true,
});
emitInternalEvent("host_deleted", userId, numericHostId, {
name: hostToDelete.name ?? hostToDelete.ip,
});
try {
pluginEvents.emit(TOPICS.hostDeleted, {
hostId: numericHostId,
userId,
});
} catch (err) {
sshLogger.warn("Failed to publish host deletion event", {
operation: "host_delete",
hostId: numericHostId,
error: err instanceof Error ? err.message : String(err),
});
}
res.json({ message: "SSH host deleted" });
} catch (err) {
sshLogger.error("Failed to delete SSH host from database", err, {
@@ -32,8 +32,6 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
disableUpdateCheck: row?.disableUpdateCheck ?? null,
confirmTabClose: row?.confirmTabClose ?? null,
hiddenRailTabs: row?.hiddenRailTabs ?? null,
aiAssistantEnabled: row?.aiAssistantEnabled ?? null,
aiReadOnlyCommands: row?.aiReadOnlyCommands ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
customThemes: row?.customThemes ?? null,
@@ -227,8 +225,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
disableUpdateCheck,
confirmTabClose,
hiddenRailTabs,
aiAssistantEnabled,
aiReadOnlyCommands,
customThemes,
customKeybindings,
terminalDefaults,
@@ -249,8 +245,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
customThemes?: string | null;
customKeybindings?: string | null;
terminalDefaults?: string | null;
@@ -392,8 +386,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
confirmSnippetExecution,
disableUpdateCheck,
confirmTabClose,
aiAssistantEnabled,
aiReadOnlyCommands,
};
for (const [key, value] of Object.entries(boolFields)) {
if (value !== undefined && value !== null && typeof value !== "boolean") {
@@ -407,10 +399,6 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (language !== undefined) updates.language = language;
if (storageMode !== undefined) updates.storageMode = storageMode;
if (hiddenRailTabs !== undefined) updates.hiddenRailTabs = hiddenRailTabs;
if (aiAssistantEnabled !== undefined)
updates.aiAssistantEnabled = aiAssistantEnabled;
if (aiReadOnlyCommands !== undefined)
updates.aiReadOnlyCommands = aiReadOnlyCommands;
if (commandAutocomplete !== undefined)
updates.commandAutocomplete = commandAutocomplete;
if (commandPaletteEnabled !== undefined)
+66
View File
@@ -0,0 +1,66 @@
import {
createCurrentHostRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRecentActivityRepository,
createCurrentSshCredentialUsageRepository,
createCurrentSyncTombstoneRepository,
} from "../database/repositories/factory.js";
import { pluginEvents, TOPICS } from "../plugins/events.js";
import { sshLogger } from "../utils/logger.js";
import { emitInternalEvent } from "./internal-events.js";
export interface DeletedHost {
id: number;
name: string;
}
/**
* Deletes a host the user owns, with everything that hangs off it. Shared by
* the host editor's delete route and ctx.hosts.delete, so a plugin deleting a
* host leaves exactly what a person deleting it would. Null when the host
* does not exist or is not the user's.
*/
export async function deleteOwnedHost(
userId: string,
hostId: number,
): Promise<DeletedHost | null> {
const host =
await createCurrentHostResolutionRepository().findHostByIdForUser(
hostId,
userId,
);
if (!host) return null;
// file manager recent/pinned/shortcuts, transfer_recent, command history
// and session recordings cascade on the host's refHost() foreign key, as
// the file-manager, ssh-terminal and session-recording plugins' adopted
// tables.
await createCurrentSshCredentialUsageRepository().deleteByHostId(hostId);
await createCurrentRecentActivityRepository().deleteByHostId(hostId);
await createCurrentRbacAccessRepository().deleteHostAccessForHost(hostId);
await createCurrentHostRepository().deleteForUser(userId, hostId);
if (host.syncId) {
await createCurrentSyncTombstoneRepository().record(
userId,
"hosts",
host.syncId,
);
}
const name = host.name ?? host.ip;
emitInternalEvent("host_deleted", userId, hostId, { name });
try {
pluginEvents.emit(TOPICS.hostDeleted, { hostId, userId });
} catch (err) {
sshLogger.warn("Failed to publish host deletion event", {
operation: "host_delete",
hostId,
error: err instanceof Error ? err.message : String(err),
});
}
return { id: hostId, name };
}
+29
View File
@@ -254,6 +254,35 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
: null;
},
delete: async (hostId: number): Promise<boolean> => {
try {
await requireWrite();
const userId = actingUser();
const { PermissionManager } =
await import("../utils/permission-manager.js");
if (
!(await PermissionManager.getInstance().hasPermission(
userId,
"hosts.delete",
))
) {
throw new Error("The acting user may not delete hosts");
}
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
const deleted = await deleteOwnedHost(userId, hostId);
await audit("hosts_delete", `host ${hostId}`, {
success: deleted !== null,
});
return deleted !== null;
} catch (error) {
await audit("hosts_delete", `host ${hostId}`, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
listOwned: async (): Promise<PluginHostRecord[]> => {
await requireWrite();
const userId = actingUser();
+5
View File
@@ -132,6 +132,11 @@ export function createPluginFetch(deps: Deps): PluginFetch {
() => controller.abort(),
Math.max(init.timeoutMs ?? 30_000, 1),
);
// The caller's signal keeps working after the headers arrive, so it
// can stop a streamed body; the timeout only covers the wait for them.
const onAbort = () => controller.abort();
if (init.signal?.aborted) controller.abort();
init.signal?.addEventListener("abort", onAbort, { once: true });
try {
return await safeOutboundFetch(
url,
+63
View File
@@ -181,6 +181,23 @@ export function createPluginContext(
const logContext = { operation: `plugin:${pluginId}` };
const declared = manifest.capabilities;
// ctx.secrets rows live in plugin_settings under the "secret" scope, one
// per user, so they go with the user and with the plugin.
const secretsRepository = async () => {
const { createCurrentPluginSettingsRepository } =
await import("../database/repositories/factory.js");
return createCurrentPluginSettingsRepository();
};
const secretOwner = (): string => {
const actor = getActor();
if (!actor) {
throw new Error(
"ctx.secrets needs an acting user: call it inside a request or ctx.asUser",
);
}
return actor;
};
const kvGet = guarded(
manifest,
"kv:own",
@@ -537,6 +554,52 @@ export function createPluginContext(
},
secrets: {
get: async (key) => {
await assertCapability(pluginId, "secrets:own", declared);
const row = await (
await secretsRepository()
).get(pluginId, "secret", secretOwner(), key);
if (!row?.value) return null;
const { decryptSystemSecret } =
await import("../utils/system-secret-crypto.js");
return decryptSystemSecret(JSON.parse(row.value) as string);
},
set: guarded(
manifest,
"secrets:own",
async (key: string, value: string | null) => {
const repository = await secretsRepository();
const owner = secretOwner();
if (value === null || value === "") {
await repository.delete(pluginId, "secret", owner, key);
return;
}
const { encryptSystemSecret } =
await import("../utils/system-secret-crypto.js");
await repository.set(
pluginId,
"secret",
owner,
key,
JSON.stringify(await encryptSystemSecret(value)),
true,
);
},
{ action: "secret_set", details: () => "stored a secret" },
),
delete: guarded(
manifest,
"secrets:own",
async (key: string) => {
await (
await secretsRepository()
).delete(pluginId, "secret", secretOwner(), key);
},
{ action: "secret_delete", details: () => "deleted a secret" },
),
offer: (key, resolve) => {
const entry = manifest.providesSecret?.find(
(candidate) => candidate.key === key,
+4
View File
@@ -397,6 +397,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
const { runDockerSettingsMigration } =
await import("./utils/crypto-migration/docker-settings-migration.js");
await runDockerSettingsMigration();
const { runAiSettingsMigration } =
await import("./utils/crypto-migration/ai-settings-migration.js");
await runAiSettingsMigration();
} catch (error) {
systemLogger.warn("Plugin runtime failed to initialize", {
operation: "plugin_init",
@@ -92,6 +92,16 @@ describe("user deletion", () => {
expect(await remaining()).toEqual([{ scope: "user", scopeId: "user-2" }]);
});
it("removes the user's ctx.secrets rows too", async () => {
const context = await seed();
await addSetting("secret", "user-1");
await addSetting("secret", "user-2");
await new UserRepository(context).delete("user-1");
expect(await remaining()).toEqual([{ scope: "secret", scopeId: "user-2" }]);
});
it("leaves admin-scope settings alone", async () => {
const context = await seed();
await addSetting("admin", null);
@@ -25,6 +25,8 @@ const h = vi.hoisted(() => ({
statusReports: [] as Array<Record<string, unknown>>,
statusPorts: [] as string[],
statusPortsRemoved: [] as string[],
userPermissions: new Set<string>(),
deleted: [] as Array<{ userId: string; hostId: number }>,
roles: [] as Array<{
id: number;
name: string;
@@ -52,6 +54,8 @@ vi.mock("../../utils/permission-manager.js", () => ({
PermissionManager: {
getInstance: () => ({
canAccessHost: async () => h.access,
hasPermission: async (_userId: string, permission: string) =>
h.userPermissions.has(permission),
}),
},
}));
@@ -147,6 +151,13 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
},
}));
vi.mock("../../hosts/delete-host.js", () => ({
deleteOwnedHost: async (userId: string, hostId: number) => {
h.deleted.push({ userId, hostId });
return hostId === 404 ? null : { id: hostId, name: "gone" };
},
}));
const { createPluginHosts } = await import("../../plugins/ctx-hosts.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
@@ -174,6 +185,8 @@ beforeEach(() => {
h.updateResult = null;
h.users = [];
h.roles = [];
h.userPermissions = new Set();
h.deleted = [];
});
describe("ctx.hosts", () => {
@@ -402,6 +415,45 @@ describe("ctx.hosts", () => {
});
});
describe("ctx.hosts.delete", () => {
it("refuses without hosts:write and audits it", async () => {
const audit = vi.fn(async () => {});
const hosts = createPluginHosts({ manifest: manifest([]), audit });
await expect(hosts.delete(1)).rejects.toBeInstanceOf(PluginCapabilityError);
expect(audit).toHaveBeenCalledWith(
"hosts_delete",
"host 1",
expect.objectContaining({ success: false }),
);
expect(h.deleted).toEqual([]);
});
it("refuses an acting user without hosts.delete", async () => {
h.granted = new Set(["hosts:write"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
await expect(hosts.delete(1)).rejects.toThrow("may not delete hosts");
expect(h.deleted).toEqual([]);
});
it("deletes the actor's own host through core's delete path", async () => {
h.granted = new Set(["hosts:write"]);
h.userPermissions = new Set(["hosts.delete"]);
const hosts = createPluginHosts({
manifest: manifest(["hosts:write"]),
audit: vi.fn(async () => {}),
});
expect(await hosts.delete(7)).toBe(true);
expect(await hosts.delete(404)).toBe(false);
expect(h.deleted).toEqual([
{ userId: "user-1", hostId: 7 },
{ userId: "user-1", hostId: 404 },
]);
});
});
describe("ctx.hosts session tracking and activity", () => {
it("counts a live session through core and releases it", () => {
h.sessions = [];
+14 -1
View File
@@ -23,6 +23,7 @@ const state = vi.hoisted(() => ({
delivered: [] as Array<{ channelId: number; title: string }>,
failChannel: null as number | null,
fetchCalls: [] as Array<{ url: string; allowlist: readonly string[] }>,
lastSignal: null as AbortSignal | null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -58,9 +59,10 @@ vi.mock("../../utils/notification-sender.js", () => ({
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: async (
url: string,
_init: RequestInit,
init: RequestInit,
allowlist: readonly string[],
) => {
state.lastSignal = init.signal ?? null;
state.fetchCalls.push({ url, allowlist });
return new Response("ok", { status: 200 });
},
@@ -181,4 +183,15 @@ describe("ctx.fetch", () => {
{ url: "https://ntfy.lan/topic", allowlist: ["ntfy.lan"] },
]);
});
it("aborts the request, and a streamed body, when the caller's signal fires", async () => {
const ctx = contextFor(["network:outbound"]);
const controller = new AbortController();
await ctx.fetch("https://example.com/stream", {
signal: controller.signal,
});
expect(state.lastSignal?.aborted).toBe(false);
controller.abort();
expect(state.lastSignal?.aborted).toBe(true);
});
});
@@ -0,0 +1,159 @@
/**
* ctx.secrets.get/set/delete: a plugin's own per-user secrets. Gated on
* secrets:own, scoped to the acting user, encrypted at rest, and the value
* never reaches the audit log.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const state = vi.hoisted(() => ({
rows: new Map<string, { value: string | null; encrypted: boolean }>(),
}));
const rowKey = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => `${pluginId}|${scope}|${scopeId}|${key}`;
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => {
const row = state.rows.get(rowKey(pluginId, scope, scopeId, key));
return row ? { ...row } : null;
},
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.rows.set(rowKey(pluginId, scope, scopeId, key), {
value,
encrypted,
});
},
delete: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => state.rows.delete(rowKey(pluginId, scope, scopeId, key)),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
decryptSystemSecret: async (value: string) => value.replace(/^sysenc:/, ""),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { runAsActor } from "../../plugins/actor.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function contextFor(capabilities: string[]) {
grants.set("demo", capabilities);
const manifest = {
id: "demo",
name: "demo",
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
const handle = createPluginHandle("demo", { activate: () => {} });
return createPluginContext(manifest, handle);
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
state.rows.clear();
});
describe("ctx.secrets store", () => {
it("refuses every call without secrets:own", async () => {
const ctx = contextFor([]);
await runAsActor("alice", "request", async () => {
await expect(ctx.secrets.get("k")).rejects.toThrow(/secrets:own/);
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/secrets:own/);
await expect(ctx.secrets.delete("k")).rejects.toThrow(/secrets:own/);
});
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_delete",
success: false,
});
expect(state.rows.size).toBe(0);
});
it("refuses a call with no acting user", async () => {
const ctx = contextFor(["secrets:own"]);
await expect(ctx.secrets.set("k", "v")).rejects.toThrow(/acting user/);
});
it("stores encrypted, per user, and never audits the value", async () => {
const ctx = contextFor(["secrets:own"]);
await runAsActor("alice", "request", () =>
ctx.secrets.set("provider:1", "sk-alice"),
);
expect(state.rows.get("demo|secret|alice|provider:1")).toEqual({
value: JSON.stringify("sysenc:sk-alice"),
encrypted: true,
});
expect(
await runAsActor("alice", "request", () => ctx.secrets.get("provider:1")),
).toBe("sk-alice");
expect(
await runAsActor("bob", "request", () => ctx.secrets.get("provider:1")),
).toBeNull();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_secret_set",
success: true,
});
expect(JSON.stringify(auditEntries)).not.toContain("sk-alice");
});
it("clears on null and on delete", async () => {
const ctx = contextFor(["secrets:own"]);
await runAsActor("alice", "request", async () => {
await ctx.secrets.set("a", "1");
await ctx.secrets.set("b", "2");
await ctx.secrets.set("a", null);
await ctx.secrets.delete("b");
expect(await ctx.secrets.get("a")).toBeNull();
expect(await ctx.secrets.get("b")).toBeNull();
});
expect(state.rows.size).toBe(0);
});
});
@@ -0,0 +1,257 @@
/**
* The AI settings migration.
*
* An upgrade must be lossless: the admin switch, the private endpoint list,
* every user's opt-in, every host's switch and every provider key have to
* reach the ai plugin, keys encrypted and gone from the table. Running it
* twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
coreSettings: new Map<string, string>(),
preferences: [] as Array<Record<string, unknown>>,
hosts: [] as Array<Record<string, unknown>>,
providers: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "ai" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
// sql.identifier() is a Name chunk carrying the table name.
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
if (sqlText.includes("FROM user_preferences")) return state.preferences;
if (sqlText.includes("FROM ssh_data")) return state.hosts;
for (const [table, rows] of state.providers) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.api_key);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.providers) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "number");
for (const row of rows) if (row.id === id) row.api_key = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
allowlistToText,
runAiSettingsMigration,
} from "../../utils/crypto-migration/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === scope && entry.scopeId === scopeId && entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.coreSettings = new Map([
["ai_globally_enabled", "true"],
["ai_private_endpoint_allowlist", '["localhost", "ollama.lan"]'],
]);
state.preferences = [
{ user_id: "u1", ai_assistant_enabled: 1, ai_read_only_commands: 0 },
{ user_id: "u2", ai_assistant_enabled: null, ai_read_only_commands: null },
];
state.hosts = [
{ id: 5, enable_ai_assistant: 1 },
{ id: 6, enable_ai_assistant: 0 },
];
state.providers = new Map([
[
"ai_providers",
[
{ id: 3, user_id: "u1", api_key: "enc:sk-one" },
{ id: 4, user_id: "u2", api_key: "sk-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("allowlistToText", () => {
it("turns the stored JSON array into one host per line", () => {
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
expect(allowlistToText("[]")).toBe("");
expect(allowlistToText("nope")).toBeNull();
});
});
describe("runAiSettingsMigration", () => {
it("moves admin, user and host settings", async () => {
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(true);
expect(value("admin", null, "privateEndpoints")).toBe(
"localhost\nollama.lan",
);
expect(value("user", "u1", "enabled")).toBe(true);
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
// Never asked stays unset, which the plugin reads as not enabled.
expect(value("user", "u2", "enabled")).toBeUndefined();
expect(value("host", "5", "enableAiAssistant")).toBe(true);
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
});
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
await runAiSettingsMigration();
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "provider:3",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:sk-one"),
});
expect(value("secret", "u2", "provider:4")).toBe("sysenc:sk-plain");
expect(
state.providers.get("ai_providers")!.every((row) => row.api_key === null),
).toBe(true);
});
it("leaves a key alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
await runAiSettingsMigration();
expect(value("secret", "u2", "provider:4")).toBeUndefined();
const row = state.providers.get("ai_providers")!.find((r) => r.id === 4);
expect(row?.api_key).toBe("sk-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.providers = new Map([
["p_ai_providers", [{ id: 8, user_id: "u1", api_key: "enc:sk-late" }]],
]);
await runAiSettingsMigration();
expect(value("secret", "u1", "provider:8")).toBe("sysenc:sk-late");
});
it("changes nothing on a second run", async () => {
await runAiSettingsMigration();
const first = JSON.stringify(state.pluginRows);
await runAiSettingsMigration();
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does not overwrite a value the plugin already has", async () => {
state.pluginRows.push({
pluginId: "ai",
scope: "admin",
scopeId: null,
key: "globallyEnabled",
value: "false",
encrypted: false,
});
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(false);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runAiSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -0,0 +1,251 @@
/**
* Moves the AI assistant's settings and provider keys into the ai plugin.
*
* - settings.ai_globally_enabled and ai_private_endpoint_allowlist become the
* admin settings globallyEnabled and privateEndpoints (one host per line).
* - user_preferences.ai_assistant_enabled and ai_read_only_commands become
* each user's enabled and allowReadOnlyCommands.
* - ssh_data.enable_ai_assistant becomes the host setting enableAiAssistant.
* - Each provider's api_key, encrypted under its owner's data key, becomes a
* ctx.secrets row "provider:<id>" for that owner, and the column is emptied
* so no key material is left in the plugin's table.
*
* The old columns stay in the database, unused: core's drizzle migrations
* run before this does, so dropping them there would lose the data first.
* The providers table is ai_providers until the plugin adopts it and
* p_ai_providers after, so both names are tried.
*
* Idempotent: nothing is written where the plugin already has a row, and a
* key is only cleared once its copy exists. Safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { DataCrypto } from "../data-crypto.js";
import { LazyFieldEncryption } from "../lazy-field-encryption.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
import { runStatement, selectRows } from "./raw-rows.js";
const PLUGIN_ID = "ai";
export interface AiSettingsMigrationResult {
admin: string[];
users: number;
hosts: number;
keys: number;
}
type Flag = number | boolean | string | null | undefined;
function isTrue(value: Flag): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
function isSet(value: Flag): boolean {
return value !== null && value !== undefined;
}
/** The legacy JSON array as the textarea the plugin now reads. */
export function allowlistToText(raw: string): string | null {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
return parsed
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n");
} catch {
return null;
}
}
async function tryRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
try {
return await selectRows<T>(query);
} catch {
// A fresh install never had the table or column.
return [];
}
}
export async function runAiSettingsMigration(): Promise<AiSettingsMigrationResult> {
const result: AiSettingsMigrationResult = {
admin: [],
users: 0,
hosts: 0,
keys: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const setIfMissing = async (
scope: "admin" | "user" | "host",
scopeId: string | null,
key: string,
value: unknown,
): Promise<boolean> => {
const existing = await pluginSettings.get(PLUGIN_ID, scope, scopeId, key);
if (existing) return false;
await pluginSettings.set(
PLUGIN_ID,
scope,
scopeId,
key,
JSON.stringify(value),
);
return true;
};
try {
const settings = createCurrentSettingsRepository();
const enabled = await settings.get("ai_globally_enabled");
if (enabled !== null && enabled !== "") {
if (await setIfMissing("admin", null, "globallyEnabled", isTrue(enabled)))
result.admin.push("globallyEnabled");
}
const allowlist = await settings.get("ai_private_endpoint_allowlist");
const text = allowlist ? allowlistToText(allowlist) : null;
if (text !== null) {
if (await setIfMissing("admin", null, "privateEndpoints", text))
result.admin.push("privateEndpoints");
}
} catch (error) {
warn("admin settings", error);
}
const users = await tryRows<{
user_id: string;
ai_assistant_enabled: Flag;
ai_read_only_commands: Flag;
}>(
sql`SELECT user_id, ai_assistant_enabled, ai_read_only_commands FROM user_preferences`,
);
for (const row of users) {
try {
let moved = false;
if (isSet(row.ai_assistant_enabled)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"enabled",
isTrue(row.ai_assistant_enabled),
)) || moved;
}
if (isSet(row.ai_read_only_commands)) {
moved =
(await setIfMissing(
"user",
row.user_id,
"allowReadOnlyCommands",
isTrue(row.ai_read_only_commands),
)) || moved;
}
if (moved) result.users++;
} catch (error) {
warn("a user's settings", error);
}
}
const hosts = await tryRows<{ id: number; enable_ai_assistant: Flag }>(
sql`SELECT id, enable_ai_assistant FROM ssh_data`,
);
for (const row of hosts) {
if (!isTrue(row.enable_ai_assistant)) continue;
try {
if (
await setIfMissing("host", String(row.id), "enableAiAssistant", true)
) {
result.hosts++;
}
} catch (error) {
warn("a host's setting", error);
}
}
for (const table of ["ai_providers", "p_ai_providers"]) {
const providers = await tryRows<{
id: number;
user_id: string;
api_key: string | null;
}>(
sql`SELECT id, user_id, api_key FROM ${sql.identifier(table)} WHERE api_key IS NOT NULL AND api_key <> ''`,
);
for (const row of providers) {
try {
const key = `provider:${row.id}`;
const existing = await pluginSettings.get(
PLUGIN_ID,
"secret",
row.user_id,
key,
);
if (!existing) {
const dek = DataCrypto.getUserDataKey(row.user_id);
if (!dek) continue;
const plaintext = LazyFieldEncryption.safeGetFieldValue(
row.api_key as string,
dek,
String(row.id),
"apiKey",
);
if (!plaintext) continue;
await pluginSettings.set(
PLUGIN_ID,
"secret",
row.user_id,
key,
JSON.stringify(await encryptSystemSecret(plaintext)),
true,
);
result.keys++;
}
await runStatement(
sql`UPDATE ${sql.identifier(table)} SET api_key = NULL WHERE id = ${row.id}`,
);
} catch (error) {
warn("a provider key", error);
}
}
}
if (
result.admin.length > 0 ||
result.users > 0 ||
result.hosts > 0 ||
result.keys > 0
) {
databaseLogger.info("Moved AI assistant settings into the ai plugin", {
operation: "ai_settings_migration",
admin: result.admin.join(", "),
users: result.users,
hosts: result.hosts,
keys: result.keys,
});
}
return result;
}
function warn(what: string, error: unknown): void {
// A failed copy must not stop the backend; the next boot tries again.
databaseLogger.warn(`AI settings migration failed for ${what}`, {
operation: "ai_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
-2
View File
@@ -48,8 +48,6 @@ class FieldCrypto {
opkssh_tokens: new Set(["sshCert", "privateKey"]),
termix_identity_ca: new Set(["privateKey"]),
vault_tokens: new Set(["sshCert", "privateKey"]),
// Third-party AI provider keys are user credentials like any other.
ai_providers: new Set(["apiKey"]),
};
static encryptField(
-3
View File
@@ -159,7 +159,6 @@ export interface HostFeatureFlags {
enableFileManager: boolean; // SSH only
enableTmuxMonitor: boolean; // SSH only
enableTerminalToolbar: boolean; // SSH, RDP, VNC, and Telnet
enableAiAssistant: boolean; // SSH only
enableRemoteDesktop: boolean; // RDP, VNC only
}
@@ -217,7 +216,6 @@ export type Host = {
scpLegacy?: boolean;
enableTmuxMonitor: boolean;
enableTerminalToolbar: boolean;
enableAiAssistant: boolean;
showTerminalInSidebar: boolean;
showFileManagerInSidebar: boolean;
showTunnelInSidebar: boolean;
@@ -368,7 +366,6 @@ export interface HostData {
enableProxmox?: boolean;
enableTmuxMonitor?: boolean;
enableTerminalToolbar?: boolean;
enableAiAssistant?: boolean;
proxmoxConfig?: ProxmoxConfig | Record<string, unknown> | null;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: ProxmoxStatsConfig | Record<string, unknown> | null;
-2
View File
@@ -104,7 +104,6 @@ export type Host = {
enableProxmox: boolean;
enableTmuxMonitor: boolean;
enableTerminalToolbar: boolean;
enableAiAssistant: boolean;
proxmoxConfig?: {
source?: {
source: "proxmox";
@@ -264,7 +263,6 @@ export type KnownTabType =
| "history"
| "ssh-tools"
| "automations"
| "ai"
| "split-screen";
/**
-1
View File
@@ -2494,7 +2494,6 @@ export function AppShell({
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: false,
enableAiAssistant: false,
enableSsh: false,
sshPort: 22,
serverTunnels: [],
-2
View File
@@ -139,8 +139,6 @@ export interface UserPreferences {
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
customThemes?: string | null;
-1
View File
@@ -27,7 +27,6 @@ export function hostToSSHHost(h: Host): SSHHost {
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableAiAssistant: h.enableAiAssistant ?? false,
enableWebUi: h.enableWebUi ?? false,
webUiConfig: (h.pluginSettings?.["web-endpoint"]?.webUiConfig as
SSHHost["webUiConfig"] | undefined) ?? { endpoints: [] },
+27
View File
@@ -20,6 +20,7 @@ import type { AxiosInstance } from "axios";
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
import { getBasePath } from "@/lib/base-path";
import { isElectron } from "@/lib/electron";
import { getDeviceId } from "@/lib/device-id";
import { websocketAuthProtocols } from "@/lib/ws-auth";
import {
buildOriginWsUrl,
@@ -75,6 +76,32 @@ export function createPluginApi(pluginId: string): AxiosInstance {
}) as AxiosInstance;
}
/**
* A raw fetch on this plugin's /plugin-api mount point, for a response the
* caller reads as a stream (server-sent events). axios buffers the whole
* body, so it cannot. Sends the same auth the shared client does.
*/
export function pluginFetch(
pluginId: string,
path: string,
init: RequestInit = {},
): Promise<Response> {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
const headers = new Headers(init.headers);
const deviceId = getDeviceId();
if (deviceId) headers.set("X-Termix-Device-ID", deviceId);
if (isElectron()) {
headers.set("X-Electron-App", "true");
const jwt = localStorage.getItem("jwt");
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
}
return fetch(`${base}${pluginApiPath(pluginId, path)}`, {
credentials: "include",
...init,
headers,
});
}
const remotePluginApis = new Map<string, AxiosInstance>();
/**
-2
View File
@@ -1041,8 +1041,6 @@
"connectionToolbar": "Connection Toolbar",
"showConnectionToolbar": "Show connection toolbar",
"showConnectionToolbarDesc": "Show the toolbar in this host's SSH, RDP, VNC, and Telnet sessions. You can also hide it temporarily from the toolbar itself.",
"enableAiAssistant": "Enable AI Assistant",
"enableAiAssistantDesc": "Allow the AI assistant to help in this host's terminal. Off by default, and still needs AI enabled for your account.",
"proxmoxLastSyncStatus": {
"success": "Success"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Verbindingsnutsbalk",
"showConnectionToolbar": "Wys verbindingsbalk",
"showConnectionToolbarDesc": "Wys die nutsbalk in hierdie gasheer se SSH-, RDP-, VNC- en Telnet-sessies. Jy kan dit ook tydelik van die nutsbalk self versteek.",
"enableAiAssistant": "Aktiveer KI-assistent",
"enableAiAssistantDesc": "Laat die KI-assistent toe om in hierdie gasheer se terminaal te help. Standaard afgeskakel, en KI moet steeds vir jou rekening geaktiveer word.",
"proxmoxLastSyncStatus": {
"success": "Sukses"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "شريط أدوات الاتصال",
"showConnectionToolbar": "إظهار شريط أدوات الاتصال",
"showConnectionToolbarDesc": "يمكنك إظهار شريط الأدوات في جلسات SSH وRDP وVNC وTelnet الخاصة بهذا المضيف. كما يمكنك إخفاؤه مؤقتًا من شريط الأدوات نفسه.",
"enableAiAssistant": "تفعيل مساعد الذكاء الاصطناعي",
"enableAiAssistantDesc": "اسمح للمساعد الذكي بالمساعدة في طرفية هذا المضيف. هذه الميزة معطلة افتراضيًا، ولا يزال يتعين تفعيلها لحسابك.",
"proxmoxLastSyncStatus": {
"success": "نجاح"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Инструменти за свързване",
"showConnectionToolbar": "Показване на лентата с инструменти за връзки",
"showConnectionToolbarDesc": "Показване на лентата с инструменти в SSH, RDP, VNC и Telnet сесиите на този хост. Можете също така временно да я скриете от самата лента с инструменти.",
"enableAiAssistant": "Активиране на AI асистент",
"enableAiAssistantDesc": "Разрешете на AI асистента да помага в терминала на този хост. Изключено по подразбиране и все още изисква AI да е активиран за вашия акаунт.",
"proxmoxLastSyncStatus": {
"success": "Успех"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "সংযোগ টুলবার",
"showConnectionToolbar": "সংযোগ টুলবার দেখান",
"showConnectionToolbarDesc": "এই হোস্টের SSH, RDP, VNC, এবং Telnet সেশনগুলিতে টুলবারটি দেখান। আপনি টুলবারটি থেকেই এটিকে সাময়িকভাবে লুকাতেও পারেন।",
"enableAiAssistant": "এআই অ্যাসিস্ট্যান্ট সক্রিয় করুন",
"enableAiAssistantDesc": "এই হোস্টের টার্মিনালে সাহায্য করার জন্য এআই অ্যাসিস্ট্যান্টকে অনুমতি দিন। এটি ডিফল্টরূপে বন্ধ থাকে এবং আপনার অ্যাকাউন্টের জন্য এআই চালু করা প্রয়োজন।",
"proxmoxLastSyncStatus": {
"success": "সাফল্য"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barra d'eines de connexió",
"showConnectionToolbar": "Mostra la barra d'eines de connexió",
"showConnectionToolbarDesc": "Mostra la barra d'eines a les sessions SSH, RDP, VNC i Telnet d'aquest amfitrió. També la pots ocultar temporalment des de la barra d'eines.",
"enableAiAssistant": "Activa l'Assistent d'IA",
"enableAiAssistantDesc": "Permet que l'assistent d'IA ajudi al terminal d'aquest amfitrió. Desactivat per defecte, però encara cal que la IA estigui activada per al teu compte.",
"proxmoxLastSyncStatus": {
"success": "Èxit"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Panel nástrojů pro připojení",
"showConnectionToolbar": "Zobrazit panel nástrojů pro připojení",
"showConnectionToolbarDesc": "Zobrazit panel nástrojů v relacích SSH, RDP, VNC a Telnet tohoto hostitele. Můžete jej také dočasně skrýt přímo v panelu nástrojů.",
"enableAiAssistant": "Povolit asistenta s umělou inteligencí",
"enableAiAssistantDesc": "Povolit asistentovi s umělou inteligencí pomáhat v terminálu tohoto hostitele. Ve výchozím nastavení vypnuto, ale stále vyžaduje, aby byla pro váš účet povolena umělá inteligence.",
"proxmoxLastSyncStatus": {
"success": "Úspěch"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Forbindelsesværktøjslinje",
"showConnectionToolbar": "Vis forbindelsesværktøjslinjen",
"showConnectionToolbarDesc": "Vis værktøjslinjen i denne værts SSH-, RDP-, VNC- og Telnet-sessioner. Du kan også skjule den midlertidigt fra selve værktøjslinjen.",
"enableAiAssistant": "Aktivér AI-assistent",
"enableAiAssistantDesc": "Tillad AI-assistenten at hjælpe i denne værts terminal. Deaktiveret som standard, og kræver stadig, at AI er aktiveret for din konto.",
"proxmoxLastSyncStatus": {
"success": "Succes"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Verbindungssymbolleiste",
"showConnectionToolbar": "Verbindungssymbolleiste anzeigen",
"showConnectionToolbarDesc": "Die Symbolleiste wird in den SSH-, RDP-, VNC- und Telnet-Sitzungen dieses Hosts angezeigt. Sie kann auch vorübergehend in der Symbolleiste selbst ausgeblendet werden.",
"enableAiAssistant": "KI-Assistent aktivieren",
"enableAiAssistantDesc": "Erlauben Sie dem KI-Assistenten, im Terminal dieses Hosts Hilfe zu leisten. Standardmäßig deaktiviert; die KI muss weiterhin für Ihr Konto aktiviert sein.",
"proxmoxLastSyncStatus": {
"success": "Erfolg"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Γραμμή εργαλείων σύνδεσης",
"showConnectionToolbar": "Εμφάνιση γραμμής εργαλείων σύνδεσης",
"showConnectionToolbarDesc": "Εμφάνιση της γραμμής εργαλείων στις συνεδρίες SSH, RDP, VNC και Telnet αυτού του κεντρικού υπολογιστή. Μπορείτε επίσης να την αποκρύψετε προσωρινά από την ίδια τη γραμμή εργαλείων.",
"enableAiAssistant": "Ενεργοποίηση Βοηθού Τεχνητής Νοημοσύνης",
"enableAiAssistantDesc": "Επιτρέψτε στον βοηθό τεχνητής νοημοσύνης να βοηθά στο τερματικό αυτού του κεντρικού υπολογιστή. Απενεργοποιημένο από προεπιλογή και εξακολουθεί να χρειάζεται ενεργοποιημένη την τεχνητή νοημοσύνη για τον λογαριασμό σας.",
"proxmoxLastSyncStatus": {
"success": "Επιτυχία"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barra de herramientas de conexión",
"showConnectionToolbar": "Mostrar barra de herramientas de conexión",
"showConnectionToolbarDesc": "Muestra la barra de herramientas en las sesiones SSH, RDP, VNC y Telnet de este host. También puedes ocultarla temporalmente desde la propia barra de herramientas.",
"enableAiAssistant": "Habilitar el asistente de IA",
"enableAiAssistantDesc": "Permite que el asistente de IA te ayude en la terminal de este host. Está desactivado por defecto y aún así necesitas tener la IA habilitada para tu cuenta.",
"proxmoxLastSyncStatus": {
"success": "Correcto"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Yhteystyökalurivi",
"showConnectionToolbar": "Näytä yhteystyökalurivi",
"showConnectionToolbarDesc": "Näytä työkalupalkki tämän isännän SSH-, RDP-, VNC- ja Telnet-istunnoissa. Voit myös piilottaa sen tilapäisesti itse työkalupalkista.",
"enableAiAssistant": "Ota tekoälyavustaja käyttöön",
"enableAiAssistantDesc": "Salli tekoälyavustaja auttaa tämän isännän terminaalissa. Oletusarvoisesti pois päältä, mutta tekoälyn on silti oltava käytössä tililläsi.",
"proxmoxLastSyncStatus": {
"success": "Menestys"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barre d'outils de connexion",
"showConnectionToolbar": "Afficher la barre d'outils de connexion",
"showConnectionToolbarDesc": "Afficher la barre d'outils dans les sessions SSH, RDP, VNC et Telnet de cet hôte. Vous pouvez également la masquer temporairement depuis la barre d'outils elle-même.",
"enableAiAssistant": "Activer l'assistant IA",
"enableAiAssistantDesc": "Autoriser l'assistant IA à vous aider dans le terminal de cet hôte. Désactivé par défaut ; l'IA doit être activée pour votre compte.",
"proxmoxLastSyncStatus": {
"success": "Succès"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "סרגל הכלים לחיבור",
"showConnectionToolbar": "הצג סרגל הכלים של החיבור",
"showConnectionToolbarDesc": "הצג את סרגל הכלים בהפעלות SSH, RDP, VNC ו-Telnet של מחשב מארח זה. ניתן גם להסתיר אותו באופן זמני מסרגל הכלים עצמו.",
"enableAiAssistant": "הפעל את עוזר הבינה המלאכותית",
"enableAiAssistantDesc": "אפשר לעוזר הבינה המלאכותית לסייע במסוף של מארח זה. כבוי כברירת מחדל, ועדיין נדרשת הפעלת בינה מלאכותית עבור חשבונך.",
"proxmoxLastSyncStatus": {
"success": "הַצלָחָה"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "कनेक्शन टूलबार",
"showConnectionToolbar": "कनेक्शन टूलबार दिखाएँ",
"showConnectionToolbarDesc": "इस होस्ट के SSH, RDP, VNC और Telnet सेशन में टूलबार दिखाएँ। आप इसे टूलबार से अस्थायी रूप से छिपा भी सकते हैं।",
"enableAiAssistant": "एआई असिस्टेंट को सक्षम करें",
"enableAiAssistantDesc": "इस होस्ट के टर्मिनल में एआई सहायक को मदद करने की अनुमति दें। यह डिफ़ॉल्ट रूप से बंद है, लेकिन आपके खाते के लिए एआई को सक्षम करना आवश्यक है।",
"proxmoxLastSyncStatus": {
"success": "सफलता"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Kapcsolat eszköztár",
"showConnectionToolbar": "Kapcsolat eszköztár megjelenítése",
"showConnectionToolbarDesc": "Eszköztár megjelenítése a gazdagép SSH, RDP, VNC és Telnet munkameneteiben. Ideiglenesen el is rejtheti magáról az eszköztárról.",
"enableAiAssistant": "AI asszisztens engedélyezése",
"enableAiAssistantDesc": "Engedélyezi a mesterséges intelligencia asszisztensének a gazdagép terminálján való segítséget. Alapértelmezés szerint ki van kapcsolva, de továbbra is engedélyeznie kell a mesterséges intelligenciát a fiókjában.",
"proxmoxLastSyncStatus": {
"success": "Siker"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Toolbar Koneksi",
"showConnectionToolbar": "Tampilkan bilah alat koneksi",
"showConnectionToolbarDesc": "Tampilkan toolbar di sesi SSH, RDP, VNC, dan Telnet host ini. Anda juga dapat menyembunyikannya sementara dari toolbar itu sendiri.",
"enableAiAssistant": "Aktifkan Asisten AI",
"enableAiAssistantDesc": "Izinkan asisten AI untuk membantu di terminal host ini. Secara default dinonaktifkan, dan AI tetap perlu diaktifkan untuk akun Anda.",
"proxmoxLastSyncStatus": {
"success": "Kesuksesan"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barra degli strumenti di connessione",
"showConnectionToolbar": "Mostra la barra degli strumenti di connessione",
"showConnectionToolbarDesc": "Mostra la barra degli strumenti nelle sessioni SSH, RDP, VNC e Telnet di questo host. Puoi anche nasconderla temporaneamente dalla barra degli strumenti stessa.",
"enableAiAssistant": "Assistente AI per l'innovazione",
"enableAiAssistantDesc": "Consenti all'assistente IA di fornire assistenza nel terminale di questo host. Disattivato per impostazione predefinita, ma è necessario abilitare l'IA per il tuo account.",
"proxmoxLastSyncStatus": {
"success": "Riuscita"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "接続ツールバー",
"showConnectionToolbar": "接続ツールバーを表示する",
"showConnectionToolbarDesc": "このホストのSSH、RDP、VNC、およびTelnetセッションでツールバーを表示します。ツールバー自体から一時的に非表示にすることもできます。",
"enableAiAssistant": "AIアシスタントを有効にする",
"enableAiAssistantDesc": "このホストのターミナルでAIアシスタントによる支援を許可します。デフォルトでは無効になっており、アカウントでAIを有効にする必要があります。",
"proxmoxLastSyncStatus": {
"success": "成功"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "연결 도구 모음",
"showConnectionToolbar": "연결 도구 모음 표시",
"showConnectionToolbarDesc": "이 호스트의 SSH, RDP, VNC 및 Telnet 세션에 툴바를 표시합니다. 툴바 자체에서 툴바를 일시적으로 숨길 수도 있습니다.",
"enableAiAssistant": "AI 어시스턴트 활성화",
"enableAiAssistantDesc": "이 호스트의 터미널에서 AI 도우미의 도움을 받을 수 있도록 허용합니다. 기본적으로 비활성화되어 있으며, 계정에 AI 기능이 활성화되어 있어야 합니다.",
"proxmoxLastSyncStatus": {
"success": "성공"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Verbindingswerkbalk",
"showConnectionToolbar": "Verbindingswerkbalk weergeven",
"showConnectionToolbarDesc": "Toon de werkbalk in de SSH-, RDP-, VNC- en Telnet-sessies van deze host. Je kunt de werkbalk ook tijdelijk verbergen via de werkbalk zelf.",
"enableAiAssistant": "AI-assistent inschakelen",
"enableAiAssistantDesc": "Sta de AI-assistent toe om te helpen in de terminal van deze host. Standaard uitgeschakeld, maar AI moet nog steeds voor uw account zijn ingeschakeld.",
"proxmoxLastSyncStatus": {
"success": "Succes"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Tilkoblingsverktøylinje",
"showConnectionToolbar": "Vis tilkoblingsverktøylinjen",
"showConnectionToolbarDesc": "Vis verktøylinjen i denne vertens SSH-, RDP-, VNC- og Telnet-økter. Du kan også skjule den midlertidig fra selve verktøylinjen.",
"enableAiAssistant": "Aktiver AI-assistent",
"enableAiAssistantDesc": "Tillat at AI-assistenten hjelper til i denne vertens terminal. Av som standard, og krever fortsatt at AI er aktivert for kontoen din.",
"proxmoxLastSyncStatus": {
"success": "Suksess"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Pasek narzędzi połączenia",
"showConnectionToolbar": "Pokaż pasek narzędzi połączenia",
"showConnectionToolbarDesc": "Pokaż pasek narzędzi w sesjach SSH, RDP, VNC i Telnet tego hosta. Możesz go również tymczasowo ukryć na samym pasku narzędzi.",
"enableAiAssistant": "Włącz asystenta AI",
"enableAiAssistantDesc": "Zezwól asystentowi AI na pomoc w terminalu tego hosta. Domyślnie wyłączone, ale nadal wymaga włączenia AI na Twoim koncie.",
"proxmoxLastSyncStatus": {
"success": "Powodzenie"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barra de ferramentas de conexão",
"showConnectionToolbar": "Mostrar barra de ferramentas de conexão",
"showConnectionToolbarDesc": "Exiba a barra de ferramentas nas sessões SSH, RDP, VNC e Telnet deste host. Você também pode ocultá-la temporariamente na própria barra de ferramentas.",
"enableAiAssistant": "Ativar o assistente de IA",
"enableAiAssistantDesc": "Permita que o assistente de IA ajude no terminal deste host. Desativado por padrão, mas ainda precisa que a IA esteja ativada para sua conta.",
"proxmoxLastSyncStatus": {
"success": "Sucesso"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Barra de ferramentas de ligação",
"showConnectionToolbar": "Mostrar barra de ferramentas de ligação",
"showConnectionToolbarDesc": "Apresente a barra de ferramentas nas sessões SSH, RDP, VNC e Telnet deste host. Também pode ocultá-la temporariamente na própria barra de ferramentas.",
"enableAiAssistant": "Ativar o assistente de IA",
"enableAiAssistantDesc": "Permita que o assistente de IA ajude no terminal deste host. Desativado por defeito, mas ainda precisa que a IA esteja ativada para a sua conta.",
"proxmoxLastSyncStatus": {
"success": "Sucesso"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Bara de instrumente Conexiune",
"showConnectionToolbar": "Afișează bara de instrumente pentru conexiuni",
"showConnectionToolbarDesc": "Afișează bara de instrumente în sesiunile SSH, RDP, VNC și Telnet ale acestei gazde. De asemenea, o poți ascunde temporar din bara de instrumente.",
"enableAiAssistant": "Activează Asistentul AI",
"enableAiAssistantDesc": "Permiteți asistentului AI să vă ajute în terminalul acestei gazde. Dezactivat în mod implicit, dar este nevoie ca AI să fie activat pentru contul dvs.",
"proxmoxLastSyncStatus": {
"success": "Succes"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Панель инструментов подключения",
"showConnectionToolbar": "Показать панель инструментов подключения",
"showConnectionToolbarDesc": "Отображать панель инструментов в сеансах SSH, RDP, VNC и Telnet этого хоста. Также можно временно скрыть её с самой панели инструментов.",
"enableAiAssistant": "Включить ИИ-помощника",
"enableAiAssistantDesc": "Разрешите помощнику с искусственным интеллектом помогать на терминале этого хоста. По умолчанию отключено, но для вашей учетной записи ИИ все равно должен быть включен.",
"proxmoxLastSyncStatus": {
"success": "Успешно"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Трака са алаткама за повезивање",
"showConnectionToolbar": "Прикажи траку са алаткама за повезивање",
"showConnectionToolbarDesc": "Прикажи траку са алаткама у SSH, RDP, VNC и Telnet сесијама овог хоста. Такође је можете привремено сакрити са саме траке са алаткама.",
"enableAiAssistant": "Омогући AI асистент",
"enableAiAssistantDesc": "Дозволите вештачкој интелигенцији да помогне у терминалу овог хоста. Искључено је подразумевано, а и даље је потребно да вештачка интелигенција буде омогућена за ваш налог.",
"proxmoxLastSyncStatus": {
"success": "Успех"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Anslutningsverktygsfält",
"showConnectionToolbar": "Visa anslutningsverktygsfältet",
"showConnectionToolbarDesc": "Visa verktygsfältet i den här värdens SSH-, RDP-, VNC- och Telnet-sessioner. Du kan också dölja det tillfälligt från själva verktygsfältet.",
"enableAiAssistant": "Aktivera AI-assistenten",
"enableAiAssistantDesc": "Tillåt AI-assistenten att hjälpa till i den här värdens terminal. Av som standard och kräver fortfarande att AI är aktiverat för ditt konto.",
"proxmoxLastSyncStatus": {
"success": "Framgång"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "แถบเครื่องมือการเชื่อมต่อ",
"showConnectionToolbar": "แสดงแถบเครื่องมือการเชื่อมต่อ",
"showConnectionToolbarDesc": "แสดงแถบเครื่องมือในเซสชัน SSH, RDP, VNC และ Telnet ของโฮสต์นี้ คุณยังสามารถซ่อนแถบเครื่องมือชั่วคราวได้จากแถบเครื่องมือโดยตรง",
"enableAiAssistant": "เปิดใช้งานผู้ช่วย AI",
"enableAiAssistantDesc": "อนุญาตให้ผู้ช่วย AI ช่วยเหลือในเทอร์มินัลของโฮสต์นี้ โดยค่าเริ่มต้นจะปิดอยู่ และยังคงต้องเปิดใช้งาน AI สำหรับบัญชีของคุณก่อน",
"proxmoxLastSyncStatus": {
"success": "ความสำเร็จ"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Bağlantı Araç Çubuğu",
"showConnectionToolbar": "Bağlantı araç çubuğunu göster",
"showConnectionToolbarDesc": "Bu sunucunun SSH, RDP, VNC ve Telnet oturumlarında araç çubuğunu gösterin. Ayrıca, araç çubuğundan geçici olarak gizleyebilirsiniz.",
"enableAiAssistant": "Yapay Zeka Asistanını Etkinleştir",
"enableAiAssistantDesc": "Bu sunucunun terminalinde yapay zeka asistanının yardımcı olmasına izin verin. Varsayılan olarak kapalıdır ve hesabınız için yapay zekanın etkinleştirilmiş olması gerekir.",
"proxmoxLastSyncStatus": {
"success": "Başarılı"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Панель інструментів з’єднання",
"showConnectionToolbar": "Показати панель інструментів з’єднань",
"showConnectionToolbarDesc": "Показувати панель інструментів у сеансах SSH, RDP, VNC та Telnet цього хоста. Ви також можете тимчасово приховати її з самої панелі інструментів.",
"enableAiAssistant": "Увімкнути помічника зі штучним інтелектом",
"enableAiAssistantDesc": "Дозволити помічнику зі штучним інтелектом допомагати в терміналі цього хоста. Вимкнено за замовчуванням, але все одно потребує ввімкнення штучного інтелекту для вашого облікового запису.",
"proxmoxLastSyncStatus": {
"success": "Успіх"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "Thanh công cụ kết nối",
"showConnectionToolbar": "Hiển thị thanh công cụ kết nối",
"showConnectionToolbarDesc": "Hiển thị thanh công cụ trong các phiên SSH, RDP, VNC và Telnet của máy chủ này. Bạn cũng có thể tạm thời ẩn nó từ chính thanh công cụ.",
"enableAiAssistant": "Kích hoạt Trợ lý AI",
"enableAiAssistantDesc": "Cho phép trợ lý AI hỗ trợ trong thiết bị đầu cuối của máy chủ này. Mặc định là tắt, nhưng bạn vẫn cần bật AI cho tài khoản của mình.",
"proxmoxLastSyncStatus": {
"success": "Thành công"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "连接工具栏",
"showConnectionToolbar": "显示连接工具栏",
"showConnectionToolbarDesc": "在主机的 SSH、RDP、VNC 和 Telnet 会话中显示工具栏。您也可以暂时将其从工具栏本身隐藏。",
"enableAiAssistant": "启用人工智能助手",
"enableAiAssistantDesc": "允许 AI 助手在此主机的终端上提供帮助。默认情况下处于关闭状态,但仍需在您的帐户中启用 AI 功能。",
"proxmoxLastSyncStatus": {
"success": "成功"
},
-2
View File
@@ -1046,8 +1046,6 @@
"connectionToolbar": "連接工具列",
"showConnectionToolbar": "顯示連接工具列",
"showConnectionToolbarDesc": "在主機的 SSH、RDP、VNC 和 Telnet 會話中顯示工具列。您也可以暫時將其從工具列本身隱藏。",
"enableAiAssistant": "啟用人工智慧助手",
"enableAiAssistantDesc": "允許 AI 助理在此主機的終端機上提供協助。預設為關閉狀態,但仍需在您的帳戶中啟用 AI 功能。",
"proxmoxLastSyncStatus": {
"success": "成功"
},
+2 -1
View File
@@ -31,7 +31,7 @@ import {
registerAction,
registerSlotContribution,
} from "@/shell/action-registry";
import { pluginApiFor, pluginWsUrl } from "@/lib/plugin-transport";
import { pluginApiFor, pluginFetch, pluginWsUrl } from "@/lib/plugin-transport";
import { registerPluginComponent } from "./component-registry";
import type { WidgetTypeDefinition } from "@/types/homepage-types";
import type { LucideIcon } from "lucide-react";
@@ -387,6 +387,7 @@ export function createPluginApp(
origin as never,
pluginHostBridge.getApi(pluginId) as never,
) as unknown as TermixApp["api"],
fetch: (path, init) => pluginFetch(pluginId, path, init),
wsUrl: (path, options) =>
pluginWsUrl(pluginId, path, options as never) as ReturnType<
TermixApp["wsUrl"]
-2
View File
@@ -165,7 +165,6 @@ export function createHostEditorForm(
scpLegacy: host?.scpLegacy ?? false,
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
enableAiAssistant: host?.enableAiAssistant ?? false,
enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false,
// The proxmox tab fills in its own defaults when this is unset.
proxmoxConfig:
@@ -428,7 +427,6 @@ export function buildHostEditorPayload(
scpLegacy: form.scpLegacy,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
enableAiAssistant: form.enableAiAssistant,
enableProxmox: form.enableProxmox,
proxmoxConfig:
form.enableProxmox || form.proxmoxConfig?.source
-9
View File
@@ -905,15 +905,6 @@ export function HostEditorGeneralTab({
onChange={(value) => setField("enableTerminalToolbar", value)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAiAssistant")}
description={t("hosts.enableAiAssistantDesc")}
>
<FakeSwitch
checked={form.enableAiAssistant}
onChange={(value) => setField("enableAiAssistant", value)}
/>
</SettingRow>
</div>
</SectionCard>
</>
-1
View File
@@ -101,7 +101,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
(proxmoxSettings.enableProxmoxStats as boolean) ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
enableAiAssistant: h.enableAiAssistant ?? false,
proxmoxConfig:
(proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? null,
proxmoxStatsConfig:
-2
View File
@@ -536,8 +536,6 @@ export function UserProfilePanel({
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
compactHostView?: boolean | null;
-1
View File
@@ -42,7 +42,6 @@ export const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
"enableTerminal",
"enableCommandHistory",
"enableTerminalToolbar",
"enableAiAssistant",
"enableFileManager",
"enableWebUi",
"enableProxmox",
-3
View File
@@ -35,7 +35,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
enableFileManager: false,
enableTunnel: false,
enableTerminalToolbar: false,
enableAiAssistant: false,
enableSsh: false,
domain: input.domain,
pluginSettings: {
@@ -74,7 +73,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
enableSsh: true,
sshPort: input.port,
};
@@ -103,7 +101,6 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
enableProxmox: host.enableProxmox,
enableTmuxMonitor: host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar,
enableAiAssistant: host.enableAiAssistant,
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ electron: false }));
vi.mock("@/main-axios", () => ({
authApi: { defaults: { baseURL: "http://server:8080/" } },
createRemoteOriginApiInstance: vi.fn(),
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => state.electron }));
vi.mock("@/lib/device-id", () => ({ getDeviceId: () => "device-1" }));
import { pluginFetch } from "@/lib/plugin-transport";
const fetchMock = vi.fn(async () => new Response("ok"));
beforeEach(() => {
state.electron = false;
fetchMock.mockClear();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
});
describe("pluginFetch", () => {
it("fetches the plugin's own mount point with the session cookie", async () => {
await pluginFetch("ai", "chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("http://server:8080/plugin-api/ai/chat/stream");
expect(init.credentials).toBe("include");
expect(init.method).toBe("POST");
const headers = init.headers as Headers;
expect(headers.get("content-type")).toBe("application/json");
expect(headers.get("x-termix-device-id")).toBe("device-1");
expect(headers.get("authorization")).toBeNull();
});
it("sends the stored token in the desktop app, like the shared client", async () => {
state.electron = true;
localStorage.setItem("jwt", "token-1");
await pluginFetch("ai", "/status");
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("http://server:8080/plugin-api/ai/status");
const headers = init.headers as Headers;
expect(headers.get("x-electron-app")).toBe("true");
expect(headers.get("authorization")).toBe("Bearer token-1");
});
});
@@ -44,7 +44,6 @@ function makeHost(overrides: Partial<Host>): Host {
authType: "credential",
enableTerminal: true,
enableTerminalToolbar: true,
enableAiAssistant: false,
enableCommandHistory: true,
enableTunnel: false,
serverTunnels: [],
@@ -52,7 +52,6 @@ function sshHost(
enableFileManager: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,
showTerminalInSidebar: true,
showFileManagerInSidebar: false,
showTunnelInSidebar: false,