@astrofoundry/pi-astro 0.33.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/agents/arcane.md +4 -3
- package/extensions/astro-discord/daily-report.test.ts +29 -1
- package/extensions/astro-discord/daily-report.ts +42 -0
- package/extensions/astro-discord/index.ts +78 -5
- package/extensions/astro-discord/reports.ts +2 -0
- package/extensions/astro-discord/restarts.ts +6 -2
- package/extensions/astro-discord/tools-report.test.ts +1 -1
- package/extensions/astro-discord/tools-report.ts +1 -1
- package/extensions/specialist-gate/approvals.test.ts +5 -0
- package/extensions/specialist-gate/index.ts +3 -0
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +19 -21
- package/skills/edge/SKILL.md +4 -0
- package/specialists/AGENTS.md +2 -0
- package/specialists/README.md +7 -5
- package/specialists/arcane/run.ts +279 -3
- package/specialists/edge/run.ts +40 -0
- package/specialists/entry/remote/spc-arcane-host-entry.sh +15 -0
- package/specialists/entry/remote/spc-arcane-install.sh +35 -0
- package/specialists/entry/remote/spc-arcane-op.py +685 -0
- package/specialists/entry/remote/spc-arcane-sudoers +4 -0
- package/specialists/lib/arcane-host.test.ts +147 -0
- package/specialists/lib/arcane-host.ts +148 -0
- package/specialists/lib/arcane-manager.ts +81 -0
- package/specialists/lib/deployment.ts +152 -0
- package/specialists/lib/repo.ts +2 -1
- package/specialists/wrappers.test.ts +19 -1
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
|
|
|
80
80
|
|
|
81
81
|
**Bundled subagents** (call them as `astro.<name>`):
|
|
82
82
|
- General-purpose: `code-reviewer`, `google-tech-lead`, `spec-writer`, `tester-api`, `tester-ui`, `ui-architect`, `ui-design-system`, `ui-frontend-developer`
|
|
83
|
-
- Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
|
|
83
|
+
- Specialists (one homelab area each, need the host setup in [specialists/README.md](specialists/README.md)): `arcane` (Arcane container platform, GitOps first; sanitized host checks, Mealie and Paperless dumps, and the documented manager upgrade with an Upgrade button on the daily report), `identity` (Zitadel, Pomerium routes through the homelab repository, certificates), `network` (UniFi reads and firewall policies, FreeRADIUS, Tailscale), `dns` (Technitium resolvers, Cloudflare zones), `edge` (Frontdoor VPS over IAP, nginx SNI allowlist through the homelab repository, public route checks), `security` (Wazuh, CrowdSec, log archive, read-only), `backup` (vzdump, Restic layers, GCS offsite, restore tests), `proxmox` (guest lifecycle, snapshots, resources; no create or destroy), `inference` (llama.cpp on Nexus, Hermes gateways and upgrades, Europa health), `media` (trendarr: trending titles, Radarr and Sonarr adds, SABnzbd pause)
|
|
84
84
|
- Release notes: `release-reviewer` (no tools; judges whether an update could break how Cortex uses a tool, for the Cortex Tools Report)
|
|
85
85
|
- Flourish pipeline: `flourish-input-vision` (image to spec), `flourish-input-narrative` (text to spec), `flourish-input-codebase` (HTML to spec), `flourish-architect` (spec to plan), `flourish-template-developer` (plan to SDK template, needs `@flourish/sdk`), `flourish-example-builder` (template to realistic example), `flourish-preview-runner` (multi-breakpoint preview + screenshots, needs `@flourish/sdk`), `flourish-reviewer` (audit)
|
|
86
86
|
|
package/agents/arcane.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: arcane
|
|
3
|
-
description: Operates the Arcane container platform and its Compose apps through the arcane tool. Project definitions change through GitOps, then the CLI syncs and verifies. Use for container and project status, logs, restarts, deploys,
|
|
3
|
+
description: Operates the Arcane container platform and its Compose apps through the arcane tool. Project definitions change through GitOps, then the CLI syncs and verifies. Use for container and project status, logs, restarts, deploys, Compose changes, host checks, database dumps, and Arcane manager upgrades on the Arcane VM.
|
|
4
4
|
tools: read, arcane, subagent
|
|
5
5
|
skills: arcane
|
|
6
6
|
systemPromptMode: replace
|
|
@@ -18,8 +18,9 @@ Rules:
|
|
|
18
18
|
- Project definitions of GitOps-managed projects (Compose files, `.env` layout, project folders) change only through `git show`, `git write`, `git commit`, `git push`, then the project's server-side sync (`arcane-cli gitops`). Never edit a managed project through `projects update` or `projects workspace`, even though the CLI allows it.
|
|
19
19
|
- Operational actions (restart, logs, stats, redeploy, image pull, prune) go straight through the CLI.
|
|
20
20
|
- Confirm destructive operations (`down`, `delete`, `prune`) are explicitly requested in the task before running them.
|
|
21
|
-
- Updating Arcane itself is
|
|
21
|
+
- Updating Arcane itself is `manager-upgrade <version>` (owner approval), which runs the whole documented procedure and rolls back on failed checks; the owner's Upgrade button on the Arcane Daily Report runs the same. Never run `system upgrade`, `projects upgrade arcane`, or a manual sync of `arcane`, and never hand host commands to the caller.
|
|
22
|
+
- Host evidence (memory, logs, OOM, capacity, storage, audit) comes from `host <command>`, never from guesses; `host db-restore` only fills an empty database and needs approval.
|
|
22
23
|
- For update questions run `update-report` and answer with its table verbatim in a code block; never answer from `projects updates`, which is stored and paginated.
|
|
23
24
|
- Before a major version update, read its notes with `release-notes`, summarise breaking changes and manual steps, then ask for one go. Never ask the caller to read release notes for you.
|
|
24
25
|
- Report facts from the JSON output. Sensitive fields are already removed; never guess at what was removed.
|
|
25
|
-
- Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`, `astro.dns`, `astro.edge
|
|
26
|
+
- Delegate through `subagent` only to another specialist (`astro.identity`, `astro.network`, `astro.dns`, `astro.edge` (public route checks), `astro.security`, `astro.backup` (Restic restores), `astro.proxmox`, `astro.inference`, `astro.media`) and only for that specialist's own area.
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
5
|
import { describeConfig } from "./admin.ts";
|
|
6
6
|
import { validateConfig } from "./config.ts";
|
|
7
|
-
import { displayVersion, failureMessage, formatDailyReport, REPORT_PREFIX } from "./daily-report.ts";
|
|
7
|
+
import { displayVersion, failureMessage, formatDailyReport, MANAGER_BUTTON, managerButton, managerOffer, managerSummary, REPORT_PREFIX } from "./daily-report.ts";
|
|
8
8
|
import { isDue, readReportState, writeReportState, zonedNow } from "./reports.ts";
|
|
9
9
|
|
|
10
10
|
const settings = { report: "arcane" as const, channelId: "1549873938140499999", time: "06:00", timeZone: "Europe/Madrid" };
|
|
@@ -129,3 +129,31 @@ describe("report schedules config", () => {
|
|
|
129
129
|
expect(errors.join(" ")).toMatch(/dailyReport was replaced by reports/);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
describe("Arcane manager upgrade offer", () => {
|
|
134
|
+
const report = (rows: unknown[]) => ({ checkedAt: "2026-10-19T04:00:00Z", images: 40, privateImages: [], rows });
|
|
135
|
+
|
|
136
|
+
it("offers the newer manager release from the arcane row only", () => {
|
|
137
|
+
expect(managerOffer(report([{ stack: "arcane", service: "arcane", running: "v2.16.1", latest: "v2.17.0", status: "update", action: "edit digest" }]))).toBe("2.17.0");
|
|
138
|
+
expect(managerOffer(report([{ stack: "arcane", service: "arcane", running: "v2.16.1", latest: "v2.16.1", status: "up-to-date" }]))).toBeNull();
|
|
139
|
+
expect(managerOffer(report([{ stack: "media", service: "sonarr", running: "4.0.1", latest: "4.0.2", status: "update" }]))).toBeNull();
|
|
140
|
+
expect(managerOffer(report([{ stack: "arcane", service: "arcane", running: "v2.16.1", latest: "sha256:abc", status: "update" }]))).toBeNull();
|
|
141
|
+
expect(managerOffer("nope")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("draws one button that only the offer can press", () => {
|
|
145
|
+
const offer = managerButton("2.17.0", "offer")[0].components?.[0];
|
|
146
|
+
expect(offer).toMatchObject({ type: 2, style: 1, label: "Upgrade Arcane to v2.17.0", custom_id: "arcane:manager:2.17.0" });
|
|
147
|
+
expect(offer?.disabled).toBeUndefined();
|
|
148
|
+
expect(managerButton("2.17.0", "running")[0].components?.[0].disabled).toBe(true);
|
|
149
|
+
expect(MANAGER_BUTTON.exec("arcane:manager:2.17.0")?.[1]).toBe("2.17.0");
|
|
150
|
+
expect(MANAGER_BUTTON.exec("arcane:manager:latest")).toBeNull();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("summarises each outcome of manager-upgrade", () => {
|
|
154
|
+
expect(managerSummary({ status: "upgraded", from: "2.16.1", to: "2.17.0", digest: `sha256:${"a".repeat(64)}`, commit: "abcdef1234567", stamp: "20261019T060500Z" })).toMatch(/^✅ Arcane v2\.16\.1 → v2\.17\.0 is running/);
|
|
155
|
+
expect(managerSummary({ status: "rolled back", from: "2.16.1", to: "2.17.0", reason: "version (\"2.16.1\")", stamp: "20261019T060500Z" })).toMatch(/^⚠️ Arcane v2\.17\.0 failed its checks/);
|
|
156
|
+
expect(managerSummary({ status: "stopped", from: "2.16.1", to: "2.17.0", reason: "sync timeout" })).toMatch(/^❌ The Arcane upgrade to v2\.17\.0 stopped before the restart: sync timeout/);
|
|
157
|
+
expect(managerSummary({})).toMatch(/unexpected answer/);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { MessageComponent } from "./rest.ts";
|
|
2
|
+
|
|
1
3
|
/** Every scheduled report starts with this, so it stands apart from conversations in the same channel. */
|
|
2
4
|
export const REPORT_PREFIX = "Arcane Daily Report:";
|
|
3
5
|
const MESSAGE_LIMIT = 1900;
|
|
@@ -63,3 +65,43 @@ export function formatDailyReport(report: unknown, timeZone: string): { content:
|
|
|
63
65
|
export function failureMessage(): string {
|
|
64
66
|
return `${REPORT_PREFIX} the version check failed today, so there is no update list. The bridge log on Cortex has the reason.`;
|
|
65
67
|
}
|
|
68
|
+
|
|
69
|
+
/** custom_id of the report's Upgrade Arcane button: `arcane:manager:<x.y.z>`. */
|
|
70
|
+
export const MANAGER_BUTTON = /^arcane:manager:(\d{1,4}\.\d{1,4}\.\d{1,4})$/;
|
|
71
|
+
|
|
72
|
+
/** The newer Arcane manager release a report offers (`2.17.0`), from the row of stack and service `arcane`, or null. */
|
|
73
|
+
export function managerOffer(report: unknown): string | null {
|
|
74
|
+
if (!isReport(report)) return null;
|
|
75
|
+
const row = report.rows.find((r) => r.stack === "arcane" && r.service === "arcane" && (r.status === "update" || r.status === "release"));
|
|
76
|
+
const m = row ? /^v?(\d{1,4}\.\d{1,4}\.\d{1,4})$/.exec(displayVersion(row.latest)) : null;
|
|
77
|
+
return m ? m[1] : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ManagerButtonState = "offer" | "running" | "upgraded" | "failed";
|
|
81
|
+
|
|
82
|
+
/** The one button under the report; only the offer is clickable. */
|
|
83
|
+
export function managerButton(version: string, state: ManagerButtonState): MessageComponent[] {
|
|
84
|
+
const label = { offer: `Upgrade Arcane to v${version}`, running: `Upgrading Arcane to v${version}…`, upgraded: `Arcane v${version} is running`, failed: `Upgrade to v${version} did not complete` }[state];
|
|
85
|
+
const style = { offer: 1, running: 2, upgraded: 3, failed: 4 }[state];
|
|
86
|
+
return [{ type: 1, components: [{ type: 2, style, label, custom_id: `arcane:manager:${version}`, ...(state === "offer" ? {} : { disabled: true }) }] }];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function short(value: unknown): string {
|
|
90
|
+
return typeof value === "string" ? value.replace(/^sha256:/, "").slice(0, 12) : "?";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The reply after an upgrade, from the wrapper's `manager-upgrade` answer. */
|
|
94
|
+
export function managerSummary(out: Record<string, unknown>): string {
|
|
95
|
+
const from = typeof out.from === "string" ? `v${out.from}` : "the previous version";
|
|
96
|
+
const to = typeof out.to === "string" ? `v${out.to}` : "the new version";
|
|
97
|
+
const reason = typeof out.reason === "string" ? out.reason.slice(0, 400) : "no reason given";
|
|
98
|
+
const backup = (out.backup as { archive?: unknown; stamp?: unknown } | undefined)?.stamp;
|
|
99
|
+
if (out.status === "upgraded") {
|
|
100
|
+
return `✅ Arcane ${from} → ${to} is running (digest ${short(out.digest)}, pin commit ${short(out.commit)}). Every check passed; other containers kept running. Cold backup: /var/backups/arcane-upgrades/${typeof backup === "string" ? backup : String(out.stamp)}.`;
|
|
101
|
+
}
|
|
102
|
+
if (out.status === "rolled back") {
|
|
103
|
+
return `⚠️ Arcane ${to} failed its checks (${reason}). ${from} runs again and the pin was reverted in git. If ${to} migrated the database, the pre-upgrade data is in /var/backups/arcane-upgrades/${String(out.stamp)} and restoring it is a manual step (arcane/AGENTS.md, Upgrade rollback).`;
|
|
104
|
+
}
|
|
105
|
+
if (out.status === "stopped") return `❌ The Arcane upgrade to ${to} stopped before the restart: ${reason}. The pin was reverted; ${from} keeps running.`;
|
|
106
|
+
return `❌ The Arcane upgrade returned an unexpected answer; check the manager with the arcane specialist before retrying.`;
|
|
107
|
+
}
|
|
@@ -14,7 +14,7 @@ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./comman
|
|
|
14
14
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, type ReportName, type ReportSchedule, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, reportSchedule, saveConfig } from "./config.ts";
|
|
15
15
|
import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
|
|
16
16
|
import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
|
|
17
|
-
import { failureMessage, formatDailyReport } from "./daily-report.ts";
|
|
17
|
+
import { failureMessage, formatDailyReport, MANAGER_BUTTON, managerButton, managerOffer, managerSummary } from "./daily-report.ts";
|
|
18
18
|
import { GatewayClient } from "./gateway.ts";
|
|
19
19
|
import { type CommandOption, IS_COMPONENTS_V2, KeyedQueue, MEDIA, MEDIA_COMMANDS, type MediaKind, type Outcome, addOutcome, applyOutcome, commandArgs, hasActiveButton, listMessage, mediaAccess, mediaPointer, parseAddButton, parseAddReport, parseMediaList, parseQueueState, queueLine } from "./media.ts";
|
|
20
20
|
import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
collectTools,
|
|
29
29
|
type Exec,
|
|
30
30
|
failureText,
|
|
31
|
+
INSTALLER,
|
|
31
32
|
layoutMessages,
|
|
32
33
|
type LineState,
|
|
33
34
|
messageComponents,
|
|
@@ -111,7 +112,7 @@ interface Interaction {
|
|
|
111
112
|
token: string;
|
|
112
113
|
type: number;
|
|
113
114
|
channel_id?: string;
|
|
114
|
-
message?: { id: string; components?: MessageComponent[] };
|
|
115
|
+
message?: { id: string; content?: string; components?: MessageComponent[] };
|
|
115
116
|
data?: { custom_id?: string; component_type?: number; name?: string; options?: CommandOption[] };
|
|
116
117
|
member?: { user?: { id: string } };
|
|
117
118
|
user?: { id: string };
|
|
@@ -134,6 +135,8 @@ const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url
|
|
|
134
135
|
/** Upper bound for one tool update from an Update button; `brew upgrade` of everything can take a while. */
|
|
135
136
|
const UPDATE_TIMEOUT_MS = 30 * 60_000;
|
|
136
137
|
const REVIEWER = "astro.release-reviewer";
|
|
138
|
+
/** Key of a running Arcane manager upgrade among the tool runs, which count as open work. */
|
|
139
|
+
const MANAGER_RUN = "arcane-manager";
|
|
137
140
|
|
|
138
141
|
interface ActiveTask {
|
|
139
142
|
specialist: string;
|
|
@@ -428,9 +431,11 @@ class Bridge {
|
|
|
428
431
|
}
|
|
429
432
|
log(`arcane report ${date}: running arcane update-report`);
|
|
430
433
|
let message: { content: string; attachment?: string };
|
|
434
|
+
let offer: string | null = null;
|
|
431
435
|
try {
|
|
432
|
-
const
|
|
433
|
-
message = formatDailyReport(
|
|
436
|
+
const report: unknown = JSON.parse(await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000));
|
|
437
|
+
message = formatDailyReport(report, schedule.timeZone);
|
|
438
|
+
offer = managerOffer(report);
|
|
434
439
|
} catch (err) {
|
|
435
440
|
log(`arcane report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
|
|
436
441
|
message = { content: failureMessage() };
|
|
@@ -440,8 +445,9 @@ class Bridge {
|
|
|
440
445
|
const posted = await this.rest.createMessage(schedule.channelId, {
|
|
441
446
|
content: message.content,
|
|
442
447
|
...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
|
|
448
|
+
...(offer ? { components: managerButton(offer, "offer") } : {}),
|
|
443
449
|
});
|
|
444
|
-
writeReportState("arcane", { date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id });
|
|
450
|
+
writeReportState("arcane", { date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id, ...(offer ? { managerOffer: offer } : {}) });
|
|
445
451
|
log(`arcane report ${date}: posted ${posted.id}`);
|
|
446
452
|
}
|
|
447
453
|
|
|
@@ -653,6 +659,69 @@ class Bridge {
|
|
|
653
659
|
if (services.length > 0) writeRestarts(addRestarts(readRestarts(), [...new Set(services)], label, new Date()));
|
|
654
660
|
}
|
|
655
661
|
|
|
662
|
+
/** The Arcane Daily Report's Upgrade button: owners only, the offer of the newest report only, one upgrade at a time. */
|
|
663
|
+
private async onManagerButton(interaction: Interaction, customId: string): Promise<void> {
|
|
664
|
+
const match = MANAGER_BUTTON.exec(customId);
|
|
665
|
+
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
|
|
666
|
+
this.refreshConfig();
|
|
667
|
+
if (!match) {
|
|
668
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "This button is no longer active.");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (!isOwner(this.config, userId)) {
|
|
672
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "Only owners upgrade Arcane.");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (this.stopping) {
|
|
676
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const state = readReportState("arcane");
|
|
680
|
+
if (!state || state.messageId !== interaction.message?.id || state.managerOffer !== match[1]) {
|
|
681
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "This offer is outdated; use the newest Arcane Daily Report.");
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (this.toolRuns.has(MANAGER_RUN)) {
|
|
685
|
+
await this.rest.ephemeralReply(interaction.id, interaction.token, "The Arcane upgrade is already running.");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
await this.rest.deferUpdate(interaction.id, interaction.token);
|
|
689
|
+
const message = { channelId: interaction.channel_id ?? "", id: interaction.message?.id ?? "", content: interaction.message?.content ?? "" };
|
|
690
|
+
await this.toolClicks.run(MANAGER_RUN, () => this.runManagerUpgrade(message, match[1], userId)).catch((err) => log(`arcane upgrade failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* The upgrade runs through the wrapper (arcane/AGENTS.md "Manager upgrades")
|
|
695
|
+
* and can take longer than an interaction token lives, so the report and the
|
|
696
|
+
* reply go through the bot's own message routes. Counted as open work, so no
|
|
697
|
+
* bridge restart lands in the middle.
|
|
698
|
+
*/
|
|
699
|
+
private async runManagerUpgrade(message: { channelId: string; id: string; content: string }, version: string, userId: string): Promise<void> {
|
|
700
|
+
await this.rest.editMessage(message.channelId, message.id, message.content, managerButton(version, "running")).catch(() => undefined);
|
|
701
|
+
this.toolRuns.set(MANAGER_RUN, { channelId: message.channelId, label: `the Arcane manager to v${version}` });
|
|
702
|
+
this.saveOpenWork();
|
|
703
|
+
log(`arcane: manager upgrade to ${version} for ${userId}`);
|
|
704
|
+
let ok = false;
|
|
705
|
+
let summary: string;
|
|
706
|
+
try {
|
|
707
|
+
const out = JSON.parse(await this.runEntry("arcane", `discord-${userId}`, ["manager-upgrade", version], 80 * 60_000)) as Record<string, unknown>;
|
|
708
|
+
ok = out.status === "upgraded";
|
|
709
|
+
summary = managerSummary(out);
|
|
710
|
+
if (ok) {
|
|
711
|
+
const cli = await this.execTool("/usr/bin/sudo", ["-n", INSTALLER, "arcane-cli", version], { timeoutMs: 10 * 60_000 });
|
|
712
|
+
summary += cli.code === 0 ? ` arcane-cli ${version} is installed for the specialists.` : ` arcane-cli ${version} was not installed (${cli.stderr.trim().split("\n").pop()?.slice(0, 200) ?? `exit ${cli.code}`}); the Cortex Tools Report offers it.`;
|
|
713
|
+
}
|
|
714
|
+
} catch (err) {
|
|
715
|
+
summary = `❌ The Arcane upgrade to v${version} did not finish: ${(err instanceof Error ? err.message : String(err)).split("\n")[0].slice(0, 300)}. Check the manager with the arcane specialist before retrying.`;
|
|
716
|
+
} finally {
|
|
717
|
+
this.toolRuns.delete(MANAGER_RUN);
|
|
718
|
+
this.saveOpenWork();
|
|
719
|
+
}
|
|
720
|
+
await this.rest.editMessage(message.channelId, message.id, message.content, managerButton(version, ok ? "upgraded" : "failed")).catch(() => undefined);
|
|
721
|
+
await this.rest.createMessage(message.channelId, { content: summary, replyTo: message.id }).catch(() => undefined);
|
|
722
|
+
log(`arcane: manager upgrade to ${version} ${ok ? "done" : "did not complete"}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
656
725
|
private async brewNodeVersion(): Promise<string> {
|
|
657
726
|
return (await this.execTool("/opt/homebrew/bin/node", ["--version"], { timeoutMs: 30_000 })).stdout.trim();
|
|
658
727
|
}
|
|
@@ -1040,6 +1109,10 @@ class Bridge {
|
|
|
1040
1109
|
await this.onToolsButton(interaction, customId);
|
|
1041
1110
|
return;
|
|
1042
1111
|
}
|
|
1112
|
+
if (customId.startsWith("arcane:manager:")) {
|
|
1113
|
+
await this.onManagerButton(interaction, customId);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1043
1116
|
const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(customId);
|
|
1044
1117
|
if (!match) return;
|
|
1045
1118
|
const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
|
|
@@ -7,6 +7,8 @@ export interface ReportState {
|
|
|
7
7
|
date: string;
|
|
8
8
|
status: "sending" | "sent" | "failed";
|
|
9
9
|
messageId?: string;
|
|
10
|
+
/** Arcane report only: the manager release its Upgrade button offers, `2.17.0`. */
|
|
11
|
+
managerOffer?: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** Local date and minutes past midnight in a time zone. */
|
|
@@ -101,9 +101,13 @@ export const ACTIVITY_COMMAND: Record<Exclude<Service, "bridge">, Command> = {
|
|
|
101
101
|
astrogate: { file: "/opt/homebrew/bin/astrogate", args: ["status"] },
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* The restart for each service; the bridge's own runs detached because it ends this process.
|
|
106
|
+
* herdr's Homebrew formula dropped its service block in 0.9.x, so its server runs from our own
|
|
107
|
+
* LaunchAgent `com.astrofoundry.herdr`; `brew services restart herdr` would unload it for good.
|
|
108
|
+
*/
|
|
105
109
|
export function restartCommand(service: Service, uid: number): Command {
|
|
106
|
-
if (service === "herdr") return { file: "/
|
|
110
|
+
if (service === "herdr") return { file: "/bin/launchctl", args: ["kickstart", "-k", `gui/${uid}/com.astrofoundry.herdr`] };
|
|
107
111
|
if (service === "astrogate") return { file: "/bin/launchctl", args: ["kickstart", "-k", `gui/${uid}/com.monadeo.astrogate`] };
|
|
108
112
|
return { file: "/bin/launchctl", args: ["kickstart", "-k", `gui/${uid}/com.astrofoundry.astro-discord`] };
|
|
109
113
|
}
|
|
@@ -337,7 +337,7 @@ describe("service activity", () => {
|
|
|
337
337
|
});
|
|
338
338
|
|
|
339
339
|
it("restarts each service through its manager", () => {
|
|
340
|
-
expect(restartCommand("herdr", 501)).toEqual({ file: "/
|
|
340
|
+
expect(restartCommand("herdr", 501)).toEqual({ file: "/bin/launchctl", args: ["kickstart", "-k", "gui/501/com.astrofoundry.herdr"] });
|
|
341
341
|
expect(restartCommand("astrogate", 501).args).toEqual(["kickstart", "-k", "gui/501/com.monadeo.astrogate"]);
|
|
342
342
|
expect(restartCommand("bridge", 501).args).toEqual(["kickstart", "-k", "gui/501/com.astrofoundry.astro-discord"]);
|
|
343
343
|
});
|
|
@@ -11,7 +11,7 @@ export const TOOLS_PREFIX = "Cortex Tools Report:";
|
|
|
11
11
|
const BREW = "/opt/homebrew/bin/brew";
|
|
12
12
|
const NPM = "/opt/homebrew/bin/npm";
|
|
13
13
|
const PI = "/opt/homebrew/bin/pi";
|
|
14
|
-
const INSTALLER = "/usr/local/sbin/specialist-install";
|
|
14
|
+
export const INSTALLER = "/usr/local/sbin/specialist-install";
|
|
15
15
|
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
16
16
|
const PI_ASTRO = "@astrofoundry/pi-astro";
|
|
17
17
|
const TRENDARR = "@astrofoundry/trendarr";
|
|
@@ -21,6 +21,11 @@ describe("approvals", () => {
|
|
|
21
21
|
expect(isRisky("arcane", ["--json", "projects", "delete", "media"])).toBe(true);
|
|
22
22
|
expect(isRisky("arcane", ["volumes", "remove", "mealie_mealie-pgdata"])).toBe(true);
|
|
23
23
|
expect(isRisky("arcane", ["system", "upgrade"])).toBe(true);
|
|
24
|
+
expect(isRisky("arcane", ["manager-upgrade", "2.17.0"])).toBe(true);
|
|
25
|
+
expect(isRisky("arcane", ["host", "db-restore", "mealie", "20261019T120000Z"])).toBe(true);
|
|
26
|
+
expect(isRisky("arcane", ["host", "radarr-movie", "tt0120737"])).toBe(true);
|
|
27
|
+
expect(isRisky("arcane", ["host", "db-dump", "mealie"])).toBe(false);
|
|
28
|
+
expect(isRisky("arcane", ["host", "container-logs", "sonarr", "2026-09-06T12:00:00Z", "2026-09-06T12:15:00Z"])).toBe(false);
|
|
24
29
|
expect(isRisky("edge", ["vps", "reboot", "--confirm", "--help"])).toBe(false);
|
|
25
30
|
expect(isRisky("proxmox", ["guests"])).toBe(false);
|
|
26
31
|
expect(isRisky("proxmox", ["set", "107", "memory=768"])).toBe(true);
|
|
@@ -27,6 +27,9 @@ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
|
|
|
27
27
|
/^(images|volumes|networks)\s+(prune|remove|rm|delete)\b/,
|
|
28
28
|
/^system\s+(prune|upgrade)\b/,
|
|
29
29
|
/^gitops\s+delete\b/,
|
|
30
|
+
// Arcane VM: upgrading or rolling back the manager, filling a database, and reading Radarr's key from its host config.
|
|
31
|
+
/^manager-(upgrade|rollback)\b/,
|
|
32
|
+
/^host\s+(db-restore|radarr-movie|radarr-interface)\b/,
|
|
30
33
|
],
|
|
31
34
|
identity: [/^zitadel\s+(DELETE|POST|PUT|PATCH)\b/, /^dmz\s+(deploy-config|pomerium-restart|render|lego-renew)\b/],
|
|
32
35
|
network: [/^unifi\s+policy-(create|update|delete)\b/],
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -56,27 +56,25 @@ Minor and patch updates need no notes unless the caller asks. When `release-note
|
|
|
56
56
|
|
|
57
57
|
## Updating Arcane itself
|
|
58
58
|
|
|
59
|
-
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
5. When the caller reports back: `["version"]` must show the new digest, `["projects", "list"]` must show `arcane` running, `["gitops", "status", "arcane"]` must be clean. Report the three facts.
|
|
59
|
+
The `arcane` project is GitOps-managed and pinned by digest (`ghcr.io/getarcaneapp/manager@sha256:...`), and the manager cannot recreate its own container. `["manager-upgrade", "<version>"]` runs the whole procedure of `arcane/AGENTS.md` "Manager upgrades" in one call: it checks that the official image's revision is the release tag's commit, records the deployed Compose file and every container on VM 100, commits and pushes the digest pin, waits for the file-only sync to report success with the committed file, pulls the image, takes a cold backup of `arcane-data` (only the manager stops), starts the new image with `--no-deps --pull never`, and checks digest, version, local UI, federated login, and that no other container restarted. A failed check reverts the pin and puts the previous image and Compose file back; the data stays as it is, and restoring the pre-upgrade data is a manual step. The answer is JSON with `status`: `upgraded`, `rolled back`, or `stopped` (nothing restarted, pin reverted).
|
|
60
|
+
|
|
61
|
+
The usual trigger is the owner's **Upgrade Arcane** button under the Arcane Daily Report, which runs the same command and installs the matching `arcane-cli` afterwards. Through the tool, `manager-upgrade` needs an owner's approval; run it only when the caller asks for the manager upgrade, after `update-report` shows the `arcane` row as an update and you have summarised its release notes. Never use `["system", "upgrade"]`, `["projects", "upgrade", "arcane"]`, a manual `gitops sync arcane`, or a hand-over of host commands.
|
|
62
|
+
|
|
63
|
+
`["manager-rollback", "<stamp>"]` (owner approval) puts back the Compose file and image recorded at that stamp when an automatic rollback did not finish; revert the pin in the repository yourself afterwards with the `git` group.
|
|
64
|
+
|
|
65
|
+
## Arcane VM operations
|
|
66
|
+
|
|
67
|
+
`["host", "<command>", ...]` runs fixed operations on VM 100 through the key of user `spc-arcane`. The checks print only allowlisted fields; raw logs and journal text never leave the guest. `["host", "--help"]` is the wrapper's help.
|
|
68
|
+
|
|
69
|
+
- `container-memory <name>`, `container-stats [name...]` (60 s sample), `container-logs <name> <since> <until> [docker|bazarr-file]` (sanitized event extracts, UTC like `2026-09-06T12:15:00Z`, at most one day), `container-oom <name> <since> <until>`, `kernel-oom <since> <until>`.
|
|
70
|
+
- `host-audit` (installed host files and units, compared with `host/` in the repository), `host-capacity`, `host-storage [area]`, `docker-usage`.
|
|
71
|
+
- `service-check grimoire|mealie` (guest-local endpoints), `crawl4ai-info`, `recyclarr-naming`, `arcane-version`, `queue-callback`, `bazarr-providers`, `mealie-api`, `queue-job <uuid>`, `object-stat grimoire <path>`.
|
|
72
|
+
- `radarr-movie <tt…> [command-id]` and `radarr-interface` read Radarr's key from its host config; each needs an owner's approval.
|
|
73
|
+
- `db-list mealie|paperless`, `db-dump mealie|paperless` (gzip pg_dump into `/var/backups/spc-arcane/<stack>/<UTC>`, checked, with row counts), `db-restore <stack> <stamp>` (owner approval): fills an **empty** database from one of those dumps, the app container stopped meanwhile. It refuses a database that holds tables; emptying one is an operator decision. Dumps are never deleted by the tool; `db-list` names those beyond the last three in `over_keep`.
|
|
74
|
+
|
|
75
|
+
Public routes (Supabase, Grimoire, Mealie URLs) are `astro.edge`'s `route-probe`, run from Cortex.
|
|
76
|
+
|
|
77
|
+
`["project-status"]` (or with a project name) lists projects with their GitOps sync: commit, state, hook state, without error text. `["deployment", "check"|"wait", "<project>", "--commit", "<sha>", "--container", "NAME=sha256:ID", ...]` compares the sync and each container's project, image ID, state, and health; `wait` polls every 10 s up to `--timeout` (default 600 s). Read `status` in the answer.
|
|
80
78
|
|
|
81
79
|
## Output
|
|
82
80
|
|
package/skills/edge/SKILL.md
CHANGED
|
@@ -11,6 +11,10 @@ The Frontdoor VPS (`frontdoor-1337`, GCP `us-east1-c`, Debian 13, public IP `35.
|
|
|
11
11
|
|
|
12
12
|
Fetches `https://auth.37pla.net/.well-known/openid-configuration` through the VPS address. `ok: true` means the whole public path works: VPS nginx, WireGuard, DMZ nginx, Pomerium, Zitadel.
|
|
13
13
|
|
|
14
|
+
## `route-probe [name...]`
|
|
15
|
+
|
|
16
|
+
HTTP status of the public routes of services on the Arcane VM, from Cortex through public DNS, the way users reach them: no credentials, no redirect followed. Without names it checks all of them: `supabase-auth-settings` (expects 401: it wants an API key), `supabase-jwks`, `supabase-openid`, `supabase-oauth-discovery`, `supabase-studio` (expects a redirect to the Pomerium sign-in), `grimoire-public`, `grimoire-public-health`, `mealie-public` (200). Each row has `httpCode`, `expected`, `ok`, `seconds`. A status says the route answered; it does not prove the application works or that a browser login succeeds. A failing route with a passing `probe` points at that service's route or container; ask `astro.identity` (Pomerium route) or `astro.arcane` (container) for their side.
|
|
17
|
+
|
|
14
18
|
## `vps <command>`
|
|
15
19
|
|
|
16
20
|
| Command | Effect |
|
package/specialists/AGENTS.md
CHANGED
|
@@ -56,6 +56,8 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
56
56
|
- `entry/specialist-install.sh` runs as root, so it runs only `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin` and the Node it installed, never Homebrew or anything else the main user can write; it checks that `/usr/local/libexec/specialist-node` and its parents belong to root and are writable by nobody else. Arguments are checked before the root test (tests exercise that part). Downloads go to a root-only temporary directory and are verified before use: Node against `SHASUMS256.txt` and the LTS flag in `index.json`, npm tarballs against the registry's `sha512` integrity, arcane-cli against the release's checksums file. Inside `/Users/specialist` it works only as `specialist` (`sudo -u specialist -H`), from a staging directory root hands over, so a link the specialist plants cannot redirect a root write. Each component switches by rename after a check (every installed wrapper's `--help`, `--version` of the new binary) and restores the previous copy when the check fails. Older versions are refused; `rollback` swaps current and previous. Homebrew's `bw` and `jq` (provision) and `gcloud` (edge) still run as `specialist`; that is a separate issue.
|
|
57
57
|
- `main()` waits for stdout and stderr to flush before `process.exit`: on a pipe, exiting right after a write drops everything past the 64 KiB buffer, which cut large answers such as `projects list --all` until 2026-09-25 (`lib/flush.test.ts`).
|
|
58
58
|
- `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images (anonymous access refused with 401 or 403) are excluded from rows, counts, and table, listed in `privateImages`, and counted in the summary line. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
|
|
59
|
+
- `arcane host <command>` runs over the forced-command key of user `spc-arcane` on VM 100. `HOST_COMMANDS` in `lib/arcane-host.ts` must list exactly the operations of `entry/remote/spc-arcane-op.py` (a test compares them); the tracked copies live in the arcane repository's `host/` (`spc-arcane-host-entry`, `spc-arcane-op`, `spc-arcane-sudoers`, `spc-arcane-install.sh`) with the probes in `scripts/arcane_ops/`. The entry only splits words (letters, digits, space, `._:/+-`) and hands them to `sudo -n /usr/local/sbin/spc-arcane-op`, the one command its sudoers line allows; the op checks the name and every argument and runs the reviewed arcane-ops probes, which print only allowlisted fields. Manager steps (`manager-*`) are internal to `manager-upgrade`/`manager-rollback` and refused through `host`. Nothing on the host deletes data: a manager rollback restores the image and Compose file, not the data volume (R3 stays manual), `db-restore` only fills an empty database, and dumps are never pruned by the tool. `host-audit` compares the host's hashes with `host/<name>` of the `arcane-gitops` checkout after a pull.
|
|
60
|
+
- `arcane manager-upgrade <version>` (`lib/arcane-manager.ts`) follows arcane/AGENTS.md "Manager upgrades": the official image's revision label must equal the release tag's commit (GitHub API), `manager-prepare` records `compose.before.yaml` and every container before the pin commit, the pin goes through the `arcane-gitops` checkout (`git` is exported from `lib/repo.ts` for this), the wait polls `gitops list` and the deployed Compose hash every 30 s for up to 30 minutes, then pull, cold backup, `up -d --no-deps --pull never`, and checks. Any failure before the restart reverts the pin (`stopped`); a failed check after it reverts the pin and runs `manager-rollback` (`rolled back`). The answer always exits 0 with `status`, so the bridge and the agent both see it.
|
|
59
61
|
- `media` runs trendarr unchanged: `trendarrCli` in `media.json` is its `dist/cli.js`, started with `process.execPath` because sudo's PATH has no node. Each call gets `HOME` set to a fresh `0700` directory under `~/.specialists/work/media/` that holds only `.trendarr/config.json` (`0600`, trendarr's own schema, built from the note fields and `media.json`) and is removed in `finally`; trendarr reads no environment variables and has no config flag, and Node's `os.homedir()` follows `$HOME` on POSIX. Every call passes `--json`; `init` is refused. An add prints trendarr's per-ID `results` and exits 0 even when trendarr exits 1 for a failed ID, because the gate shows only stderr on failure; other failures carry trendarr's stderr without its `warning:` lines, which go into the JSON as `warnings`. The bridge's slash commands and Add buttons call the same wrapper through the entry point (`extensions/astro-discord/media.ts`).
|
|
60
62
|
- `arcane release-notes <stack>[/<service>]` is `lib/releases.ts`: a single-image check and `resolveRef` give the running and latest versions, `HttpRegistry.labels` reads `org.opencontainers.image.source` from the latest image (index to linux/amd64 manifest to config blob), and the anonymous GitHub releases API (60 requests an hour, up to 3 pages of 100) gives the releases in between, oldest first. `condenseNotes` keeps each release under 12,000 characters by section priority; `capTotal` drops the oldest releases past 40,000 characters in total.
|
|
61
63
|
|
package/specialists/README.md
CHANGED
|
@@ -4,11 +4,11 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
|
|
|
4
4
|
|
|
5
5
|
| Agent | Area | What it can do |
|
|
6
6
|
|---|---|---|
|
|
7
|
-
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; `update-report` (fresh check of every stack with running and latest release versions); `release-notes <stack>/<service>` (GitHub notes between the two versions); Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
|
|
7
|
+
| `astro.arcane` | Arcane container platform (VM 100) and its GitOps repository | Every `arcane-cli` operation except its own `config`, `auth`, `self-update`; `update-report` (fresh check of every stack with running and latest release versions); `release-notes <stack>/<service>` (GitHub notes between the two versions); `project-status`, `deployment check|wait`; `manager-upgrade <version>` and `manager-rollback <stamp>` (the documented manager upgrade, owner approval); `host <command>`: sanitized host checks (memory, stats, logs, OOM, kernel OOM, audit, capacity, storage, Docker usage, service and queue checks) and `db-list|db-dump|db-restore` for Mealie and Paperless through user `spc-arcane` on VM 100; Compose changes of GitOps-managed projects through `git show|write|commit|push`, then `arcane-cli gitops` sync |
|
|
8
8
|
| `astro.identity` | Zitadel; Pomerium, nginx, lego on VM 104; `02-pulsar-proxmox/dmz/` in the homelab repository | Any Zitadel API call (v2, management, admin, auth) with responses redacted; `dmz status|journal|config-template|render|pomerium-restart|nginx-reload|lego-renew|deploy-config`; `git` on the homelab checkout, writes limited to `02-pulsar-proxmox/dmz/` |
|
|
9
9
|
| `astro.network` | UniFi, FreeRADIUS (LXC 108), Tailscale (LXC 105) | Read UniFi sites, devices, clients, networks, zones, firewall policies, WANs; create, update, delete firewall policies; RADIUS and Tailscale checks on Pulsar |
|
|
10
10
|
| `astro.dns` | Technitium primary (Synapse) and secondary (LXC 107); Cloudflare zones `37pla.net`, `monadeo.com` | Read zones and records on both resolvers, resolve, stats; create and delete zones, add, update, delete records on the primary; Cloudflare zones and record add, update, delete |
|
|
11
|
-
| `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|package|changelog|auto-upgrades|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
|
|
11
|
+
| `astro.edge` | Frontdoor VPS (`frontdoor-1337`) over an IAP tunnel; `00-frontdoor-vps/` in the homelab repository | `probe` the public path; `route-probe` (status of the Supabase, Grimoire, and Mealie public routes from Cortex); `vps status|nginx-conf|nginx-test|journal|wg|bouncer|updates|package|changelog|auto-upgrades|deploy-nginx|reboot --confirm`; `git` on the homelab checkout, writes limited to `00-frontdoor-vps/` |
|
|
12
12
|
| `astro.security` | Wazuh, CrowdSec, rsyslog archive on VM 106 | Read-only: `status`, `attention`, `agents`, `alerts`, `decisions`, `bouncers`, `metrics`, `wazuh-alerts`, `wazuh-vulns`, `wazuh-log`, `remote-hosts`, `remote-log`, `journal` |
|
|
13
13
|
| `astro.backup` | vzdump, the shared Restic repository (Pulsar host config, Arcane app backups), GCS offsite from LXC 102 | Status, journals, and manual runs of every layer; `restic-snapshots|stats|ls`, `restic-restore` into `/var/tmp/spc-restore` on Pulsar, `restore-clean` |
|
|
14
14
|
| `astro.proxmox` | Proxmox VE on Pulsar | API token: `guests`, `guest`, `get <path>`, `tasks`; `start`, `shutdown|reboot|stop --confirm`; snapshots create, delete, rollback; `set` of CPU, memory, options, network. Pulsar key: `host-status`, `host-journal`, `updates`, `guest-exec <vmid> <status|journal|df|updates>` |
|
|
@@ -33,7 +33,7 @@ Limits: `network` writes only firewall policies on UniFi. `security` is read-onl
|
|
|
33
33
|
Host: Cortex (macOS on Apple silicon; Homebrew `bw`, `jq`, `gcloud-cli` with `python@3.14`, git; the specialists' Node and trendarr come from `specialist-install`). Steps 1 to 3 are one-time on the services; 4 to 8 are per host.
|
|
34
34
|
|
|
35
35
|
1. **Vaultwarden.** Account `specialists@monadeo.com` with **Can view** on collection `specialists` in organization `homelab`. Secure Notes with Hidden fields (`_B64` fields hold `base64 -i <keyfile> | tr -d '\n'`):
|
|
36
|
-
- `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain).
|
|
36
|
+
- `arcane`: `ZITADEL_KEY_JSON`, `GITOPS_DEPLOY_KEY_B64`, `ZITADEL_USER_ID` (plain), `SSH_KEY_ARCANE_HOST_B64` (user `spc-arcane` on VM 100, for `host`, `manager-*`), and optionally `MEALIE_API_KEY` (only `host mealie-api` reads it).
|
|
37
37
|
- `identity`: `ZITADEL_PAT`, `SSH_KEY_DMZ_B64`, `HOMELAB_DEPLOY_KEY_B64`.
|
|
38
38
|
- `network`: `UNIFI_API_KEY`, `SSH_KEY_PULSAR_B64`.
|
|
39
39
|
- `dns`: `TECHNITIUM_TOKEN`, `TECHNITIUM2_TOKEN` (Technitium API tokens of the primary and the secondary), `CLOUDFLARE_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`.
|
|
@@ -109,13 +109,14 @@ Host: Cortex (macOS on Apple silicon; Homebrew `bw`, `jq`, `gcloud-cli` with `py
|
|
|
109
109
|
- Frontdoor VPS: `spc-edge-frontdoor-install.sh` creates user `spc-edge` (group `systemd-journal`), installs `spc-edge-frontdoor-entry.sh` and the sudoers file `spc-edge-frontdoor-sudoers`, and writes `authorized_keys`. Copy the three files and the public key to `/tmp` with `gcloud compute scp --tunnel-through-iap`, then run it as root.
|
|
110
110
|
- Pulsar, root, three more keys: `spc-backup-pulsar-entry.sh`, `spc-proxmox-pulsar-entry.sh`, `spc-inference-pulsar-entry.sh` (tracked in `02-pulsar-proxmox/pulsar/system/`).
|
|
111
111
|
- VM 100, user `arcane`: `spc-backup-arcane-entry.sh` (tracked as `host/spc-backup-arcane-entry` in the `astronaute77/arcane` repository, next to the host backup scripts).
|
|
112
|
+
- VM 100, user `spc-arcane` (no docker group): `spc-arcane-host-entry.sh` hands each command to the root-owned `spc-arcane-op.py` (installed as `/usr/local/sbin/spc-arcane-op`), the only command `spc-arcane-sudoers` allows; it runs the reviewed arcane-ops probes from `/usr/local/lib/spc-arcane` and the fixed manager and database steps. `spc-arcane-install.sh` creates the user and installs all of it from `/tmp/spc-arcane`; the files are tracked under `host/` in the arcane repository, the probes under `scripts/arcane_ops/`. Add `"hostAddress": "10.0.40.30", "hostUser": "spc-arcane"` to `arcane.json`; other arcane commands do not read them.
|
|
112
113
|
- Nexus, user `nexus`: `spc-inference-nexus-entry.sh` as `/usr/local/sbin/spc-inference-nexus-entry` (tracked in `04-nexus-macstudio/system/`).
|
|
113
114
|
- LXC 101, user `hermes`: `spc-inference-hermes-entry.sh` (tracked in `02-pulsar-proxmox/hermes/system/`).
|
|
114
115
|
8. **Pi**, as the main user: `~/.pi/agent/specialists.json` (below) and the specialist blocks in `~/.pi/agent/security-guard.json` (`specialist-cli`, `arcane-cli`, `-u specialist` in `operations`; `/Users/specialist/**` in `reads` and `writes`; keep the default rules next to them).
|
|
115
116
|
```json
|
|
116
117
|
{ "entry": "/usr/local/bin/specialist-cli", "user": "specialist",
|
|
117
118
|
"specialists": [
|
|
118
|
-
{ "agent": "astro.arcane", "tool": "arcane", "service": "arcane", "timeoutMs":
|
|
119
|
+
{ "agent": "astro.arcane", "tool": "arcane", "service": "arcane", "timeoutMs": 5400000 },
|
|
119
120
|
{ "agent": "astro.identity", "tool": "identity", "service": "identity", "timeoutMs": 120000 },
|
|
120
121
|
{ "agent": "astro.network", "tool": "network", "service": "network", "timeoutMs": 120000 },
|
|
121
122
|
{ "agent": "astro.dns", "tool": "dns", "service": "dns", "timeoutMs": 60000 },
|
|
@@ -135,8 +136,9 @@ The `astro-discord` extension gives Discord users access to the specialists from
|
|
|
135
136
|
|
|
136
137
|
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again. Scheduled reports are a list in the config, one entry per report: `"reports": [{ "report": "arcane", "channelId": "<id>", "time": "06:00", "timeZone": "Europe/Madrid" }, { "report": "tools", "channelId": "<admin channel id>", "time": "06:00", "timeZone": "Europe/Madrid" }]` (the earlier single `dailyReport` key is refused; move it into the list). The Arcane report posts an "Arcane Daily Report:" message every morning: each service with an update as current to target version, from a fresh `update-report`; replying to it asks the arcane specialist with the report as context. The tools report posts "Cortex Tools Report:": every tool on Cortex with a newer version (Homebrew packages installed on request and casks, Pi, pi-astro, and through `specialist-install` the wrappers, arcane-cli at the Arcane server's version, trendarr, and the specialist Node at the latest LTS), a release-notes verdict from `astro.release-reviewer` for the tools the specialists build on, an Update button per line, and one Upgrade everything for Homebrew. Only owners can press them; nothing is installed otherwise. After an update that needs it, the bridge, herdr, or Astrogate restarts as soon as nothing runs on it; after a day of waiting, a Restart now button appears. The bridge calls `sudo -n /usr/local/sbin/specialist-install` through the sudoers rule from step 4.
|
|
137
138
|
- Media: where the `media` specialist is present (`config channel <#channel> always media`), `/trending [type] [min_rating] [count]`, `/search <title> [type]`, and `/downloads <status|pause|resume|unpause>` run the wrapper without a model. Lists arrive as two messages, Movies and Series, each title with a button beside it: **Add** adds it at once and turns into Added, In library, or Not found (Retry after a failure); a greyed-out In library or No IMDb ID button needs nothing. Notes such as "downloads are paused" go only to the user who clicked. Owners and `access.media` users may use them; anyone else gets a refusal only they see, and in a channel without `media` the bot names the channels that have it. Plain messages in the channel still go to the agent, for requests by name ("add the new Dune"). At startup the bridge sets the three commands as the application's global commands (servers only).
|
|
139
|
+
- Arcane manager: when the morning Arcane Daily Report shows a newer manager release, the message carries an **Upgrade Arcane to vX** button. Only an owner's click on the newest report runs `arcane manager-upgrade <version>` through the entry point (one at a time, counted as open work so no bridge restart interrupts it); the button turns into the outcome, a reply gives the result, and a successful upgrade installs the matching `arcane-cli` with `specialist-install`.
|
|
138
140
|
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
|
|
139
|
-
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane
|
|
141
|
+
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane destructive actions: `down`, deletes, prunes, image, volume, or network removal, `system upgrade`, `gitops delete`, plus `manager-upgrade`, `manager-rollback`, `host db-restore`, and the Radarr host-config reads; help lookups never ask) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
|
|
140
142
|
- Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
|
|
141
143
|
- Discord side: application `Cortex` with a bot, Message Content Intent enabled, invited with scopes `bot` and `applications.commands` and permissions View Channel, Send Messages, Attach Files, Read Message History, Add Reactions.
|
|
142
144
|
|