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

+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,