@astrofoundry/pi-astro 0.21.1 → 0.22.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 -0
- package/extensions/astro-discord/approvals.ts +124 -0
- package/extensions/astro-discord/chunk.ts +50 -0
- package/extensions/astro-discord/commands.ts +36 -0
- package/extensions/astro-discord/config.ts +108 -0
- package/extensions/astro-discord/discord.test.ts +173 -0
- package/extensions/astro-discord/gateway.ts +212 -0
- package/extensions/astro-discord/index.ts +275 -0
- package/extensions/astro-discord/rest.ts +102 -0
- package/extensions/astro-discord/setup.ts +175 -0
- package/extensions/astro-subagents/child.ts +8 -3
- package/extensions/specialist-gate/approvals.test.ts +47 -0
- package/extensions/specialist-gate/index.ts +84 -0
- package/package.json +1 -1
- package/specialists/AGENTS.md +5 -1
- package/specialists/README.md +10 -0
- package/specialists/lib/repo.ts +12 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Interactive setup for the Discord bridge. Run on the host that will hold
|
|
4
|
+
* the bot, as the user that runs Pi:
|
|
5
|
+
* node --disable-warning=ExperimentalWarning <package>/extensions/astro-discord/setup.ts
|
|
6
|
+
* Prompts for the bot token and Discord ids, checks each against the API,
|
|
7
|
+
* writes ~/.config/astro-discord/{token,config.json} (0600), the LaunchAgent,
|
|
8
|
+
* and prints the commands to start it. Imports nothing from Pi on purpose.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline/promises";
|
|
15
|
+
import { Writable } from "node:stream";
|
|
16
|
+
import { configDir, configPath, tokenPath, validateConfig } from "./config.ts";
|
|
17
|
+
import { DiscordRest } from "./rest.ts";
|
|
18
|
+
|
|
19
|
+
const LABEL = "com.astrofoundry.astro-discord";
|
|
20
|
+
const SNOWFLAKE = /^\d{17,20}$/;
|
|
21
|
+
|
|
22
|
+
class MutableOutput extends Writable {
|
|
23
|
+
muted = false;
|
|
24
|
+
_write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void {
|
|
25
|
+
if (!this.muted) process.stdout.write(chunk);
|
|
26
|
+
callback();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main(): Promise<void> {
|
|
31
|
+
const output = new MutableOutput();
|
|
32
|
+
const rl = createInterface({ input: process.stdin, output, terminal: true });
|
|
33
|
+
const ask = async (question: string, hidden = false): Promise<string> => {
|
|
34
|
+
output.muted = false;
|
|
35
|
+
process.stdout.write(question);
|
|
36
|
+
output.muted = hidden;
|
|
37
|
+
const answer = await rl.question("");
|
|
38
|
+
output.muted = false;
|
|
39
|
+
if (hidden) process.stdout.write("\n");
|
|
40
|
+
return answer.trim();
|
|
41
|
+
};
|
|
42
|
+
const askIds = async (question: string, min: number): Promise<string[]> => {
|
|
43
|
+
for (;;) {
|
|
44
|
+
const ids = (await ask(question)).split(/[\s,]+/).filter(Boolean);
|
|
45
|
+
if (ids.length >= min && ids.every((id) => SNOWFLAKE.test(id))) return ids;
|
|
46
|
+
console.log(` need ${min === 0 ? "" : "at least one "}Discord id${min > 1 ? "s" : ""} (17 to 20 digits, comma separated)`);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
console.log("astro-discord setup. Nothing is written before every value has been checked.\n");
|
|
51
|
+
const existing = existsSync(tokenPath()) ? readFileSync(tokenPath(), "utf-8").trim() : "";
|
|
52
|
+
let token = await ask(existing ? "Bot token (Enter keeps the stored one): " : "Bot token (Developer Portal, Bot, Reset Token): ", true);
|
|
53
|
+
if (token.length === 0 && existing) token = existing;
|
|
54
|
+
const rest = new DiscordRest(token);
|
|
55
|
+
const me = await rest.currentUser();
|
|
56
|
+
console.log(` token ok: bot ${me.username} (${me.id})`);
|
|
57
|
+
|
|
58
|
+
let applicationId: string;
|
|
59
|
+
for (;;) {
|
|
60
|
+
applicationId = await ask("Application ID (General Information): ");
|
|
61
|
+
if (SNOWFLAKE.test(applicationId)) break;
|
|
62
|
+
console.log(" not a Discord id");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let guildId: string;
|
|
66
|
+
for (;;) {
|
|
67
|
+
guildId = (await askIds("Server ID (right-click the server, Copy Server ID): ", 1))[0];
|
|
68
|
+
try {
|
|
69
|
+
const guild = (await (await fetch(`https://discord.com/api/v10/guilds/${guildId}`, { headers: { Authorization: `Bot ${token}` } })).json()) as { name?: string; message?: string };
|
|
70
|
+
if (guild.name) {
|
|
71
|
+
console.log(` server ok: ${guild.name}`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
console.log(` Discord says: ${guild.message ?? "unknown server"}; is the bot invited there?`);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.log(` check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let channelIds: string[];
|
|
81
|
+
for (;;) {
|
|
82
|
+
channelIds = await askIds("Channel ID(s) the bot listens in, comma separated: ", 1);
|
|
83
|
+
const names: string[] = [];
|
|
84
|
+
let ok = true;
|
|
85
|
+
for (const id of channelIds) {
|
|
86
|
+
try {
|
|
87
|
+
const channel = await rest.channel(id);
|
|
88
|
+
if (channel.guild_id !== guildId) {
|
|
89
|
+
console.log(` channel ${id} is not in that server`);
|
|
90
|
+
ok = false;
|
|
91
|
+
} else names.push(`#${channel.name ?? id}`);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.log(` channel ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
94
|
+
ok = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (ok) {
|
|
98
|
+
console.log(` channels ok: ${names.join(", ")}`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const owners = await askIds("Owner user ID(s), may use every specialist and approve risky calls: ", 1);
|
|
104
|
+
for (const id of owners) {
|
|
105
|
+
const user = (await (await fetch(`https://discord.com/api/v10/users/${id}`, { headers: { Authorization: `Bot ${token}` } })).json()) as { username?: string };
|
|
106
|
+
console.log(` owner ${id}: ${user.username ?? "unknown user"}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const access: Record<string, string[]> = {};
|
|
110
|
+
console.log("Extra users per specialist (Enter on an empty specialist name to finish):");
|
|
111
|
+
for (;;) {
|
|
112
|
+
const name = await ask(" specialist name (dns, edge, ...): ");
|
|
113
|
+
if (name.length === 0) break;
|
|
114
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
115
|
+
console.log(" lowercase name only");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
access[name] = await askIds(` user id(s) allowed to use ${name}: `, 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let timeout = 30;
|
|
122
|
+
const timeoutAnswer = await ask("Approval timeout in minutes [30]: ");
|
|
123
|
+
if (timeoutAnswer.length > 0) timeout = Number(timeoutAnswer);
|
|
124
|
+
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
const config = validateConfig({ applicationId, guildId, channelIds, owners, access, approvalTimeoutMinutes: timeout }, errors);
|
|
127
|
+
if (!config) {
|
|
128
|
+
console.error(`invalid configuration: ${errors.join("; ")}`);
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
mkdirSync(configDir(), { recursive: true, mode: 0o700 });
|
|
133
|
+
chmodSync(configDir(), 0o700);
|
|
134
|
+
writeFileSync(tokenPath(), `${token}\n`, { mode: 0o600 });
|
|
135
|
+
chmodSync(tokenPath(), 0o600);
|
|
136
|
+
writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
137
|
+
chmodSync(configPath(), 0o600);
|
|
138
|
+
|
|
139
|
+
const pi = execFileSync("/bin/sh", ["-c", "command -v pi"], { encoding: "utf-8", env: { ...process.env, PATH: `${process.env.PATH ?? ""}:/opt/homebrew/bin:${homedir()}/.local/bin` } }).trim();
|
|
140
|
+
const runScript = join(configDir(), "run.sh");
|
|
141
|
+
const logFile = join(homedir(), "Library", "Logs", "astro-discord.log");
|
|
142
|
+
writeFileSync(
|
|
143
|
+
runScript,
|
|
144
|
+
`#!/bin/sh\n# Headless Pi host for the Discord bridge. Pi's RPC mode exits when stdin closes, so a pipe keeps it open.\nexport PATH="/opt/homebrew/bin:$HOME/.local/bin:/usr/bin:/bin"\nexport ASTRO_DISCORD=1\ncd "$HOME"\ntail -f /dev/null | exec ${pi} --mode rpc --no-session\n`,
|
|
145
|
+
{ mode: 0o700 },
|
|
146
|
+
);
|
|
147
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
148
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
149
|
+
<plist version="1.0">
|
|
150
|
+
<dict>
|
|
151
|
+
<key>Label</key><string>${LABEL}</string>
|
|
152
|
+
<key>ProgramArguments</key><array><string>/bin/sh</string><string>${runScript}</string></array>
|
|
153
|
+
<key>RunAtLoad</key><true/>
|
|
154
|
+
<key>KeepAlive</key><true/>
|
|
155
|
+
<key>StandardOutPath</key><string>${logFile}</string>
|
|
156
|
+
<key>StandardErrorPath</key><string>${logFile}</string>
|
|
157
|
+
</dict>
|
|
158
|
+
</plist>
|
|
159
|
+
`;
|
|
160
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
161
|
+
mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
|
|
162
|
+
writeFileSync(plistPath, plist, { mode: 0o644 });
|
|
163
|
+
rl.close();
|
|
164
|
+
|
|
165
|
+
console.log(`\nwrote ${tokenPath()}, ${configPath()}, ${runScript}, ${plistPath}`);
|
|
166
|
+
console.log("Start or restart the bridge:");
|
|
167
|
+
console.log(` launchctl bootout gui/$(id -u)/${LABEL} 2>/dev/null; launchctl bootstrap gui/$(id -u) ${plistPath}`);
|
|
168
|
+
console.log(`Log: tail -f ${logFile}`);
|
|
169
|
+
console.log(`In Discord, in one of the channels: help`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
174
|
+
process.exit(1);
|
|
175
|
+
});
|
|
@@ -64,9 +64,10 @@ export function childRemaining(parentRemaining: number, agent: AgentConfig): num
|
|
|
64
64
|
return agent.maxSubagentDepth === undefined ? inherited : Math.min(inherited, agent.maxSubagentDepth);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[]): string {
|
|
67
|
+
export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[], extra?: string): string {
|
|
68
68
|
const parts = [agent.systemPrompt];
|
|
69
69
|
for (const skill of skills) parts.push(`## Skill: ${skill.name}\n\n${skill.body}`);
|
|
70
|
+
if (extra) parts.push(extra);
|
|
70
71
|
return parts.filter((p) => p.length > 0).join("\n\n");
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -177,6 +178,10 @@ export interface RunOptions {
|
|
|
177
178
|
signal?: AbortSignal;
|
|
178
179
|
onUpdate?: (result: RunResult) => void;
|
|
179
180
|
invocation?: (args: string[]) => Invocation;
|
|
181
|
+
/** Extra variables for the child process, for example an approval endpoint. */
|
|
182
|
+
env?: Record<string, string>;
|
|
183
|
+
/** Text appended to the system prompt, for example channel rules. */
|
|
184
|
+
extraSystemPrompt?: string;
|
|
180
185
|
}
|
|
181
186
|
|
|
182
187
|
export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
@@ -192,7 +197,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
|
192
197
|
model: agent.model ?? options.defaults.model,
|
|
193
198
|
step: options.step,
|
|
194
199
|
};
|
|
195
|
-
const prompt = composeSystemPrompt(agent, options.skills);
|
|
200
|
+
const prompt = composeSystemPrompt(agent, options.skills, options.extraSystemPrompt);
|
|
196
201
|
let tmpDir: string | null = null;
|
|
197
202
|
let promptFile: string | null = null;
|
|
198
203
|
if (prompt.length > 0) {
|
|
@@ -203,7 +208,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
|
|
|
203
208
|
try {
|
|
204
209
|
const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults });
|
|
205
210
|
const invocation = (options.invocation ?? piInvocation)(args);
|
|
206
|
-
const env = buildChildEnv(agent, options.depth, options.remaining);
|
|
211
|
+
const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
|
|
207
212
|
let aborted = false;
|
|
208
213
|
const exitCode = await new Promise<number>((resolve) => {
|
|
209
214
|
// Own process group so a kill reaches the child's own tool processes, not only pi itself.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isRisky, requestApproval } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
describe("approvals", () => {
|
|
5
|
+
it("marks interrupting and destructive calls as risky", () => {
|
|
6
|
+
expect(isRisky("edge", ["vps", "reboot", "--confirm"])).toBe(true);
|
|
7
|
+
expect(isRisky("edge", ["vps", "status"])).toBe(false);
|
|
8
|
+
expect(isRisky("edge", ["vps", "deploy-nginx"])).toBe(true);
|
|
9
|
+
expect(isRisky("dns", ["technitium", "primary", "zones"])).toBe(false);
|
|
10
|
+
expect(isRisky("dns", ["technitium", "primary", "record-delete", "z", "d", "A", "ipAddress=1.1.1.1"])).toBe(true);
|
|
11
|
+
expect(isRisky("dns", ["cloudflare", "record-add", "37pla.net", "{}"])).toBe(true);
|
|
12
|
+
expect(isRisky("identity", ["zitadel", "GET", "/v2/users"])).toBe(false);
|
|
13
|
+
expect(isRisky("identity", ["zitadel", "DELETE", "/v2/users/1"])).toBe(true);
|
|
14
|
+
expect(isRisky("identity", ["dmz", "deploy-config"])).toBe(true);
|
|
15
|
+
expect(isRisky("arcane", ["projects", "list"])).toBe(false);
|
|
16
|
+
expect(isRisky("arcane", ["projects", "redeploy", "mealie"])).toBe(true);
|
|
17
|
+
expect(isRisky("arcane", ["gitops", "sync", "arcane", "--yes"])).toBe(true);
|
|
18
|
+
expect(isRisky("proxmox", ["guests"])).toBe(false);
|
|
19
|
+
expect(isRisky("proxmox", ["set", "107", "memory=768"])).toBe(true);
|
|
20
|
+
expect(isRisky("proxmox", ["shutdown", "107", "--confirm"])).toBe(true);
|
|
21
|
+
expect(isRisky("security", ["decisions"])).toBe(false);
|
|
22
|
+
expect(isRisky("inference", ["hermes-restart", "api"])).toBe(true);
|
|
23
|
+
expect(isRisky("backup", ["restic-snapshots"])).toBe(false);
|
|
24
|
+
expect(isRisky("backup", ["vzdump-run", "107"])).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("creates a ticket and polls until decided", async () => {
|
|
28
|
+
const seen: string[] = [];
|
|
29
|
+
let polls = 0;
|
|
30
|
+
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
31
|
+
seen.push(`${init?.method ?? "GET"} ${String(url)}`);
|
|
32
|
+
if (init?.method === "POST") {
|
|
33
|
+
expect(JSON.parse(init.body as string)).toEqual({ agent: "astro.edge", service: "edge", args: ["vps", "reboot", "--confirm"] });
|
|
34
|
+
return new Response(JSON.stringify({ id: "t1" }), { status: 201 });
|
|
35
|
+
}
|
|
36
|
+
polls++;
|
|
37
|
+
return new Response(JSON.stringify({ id: "t1", decision: polls < 3 ? null : "deny" }), { status: 200 });
|
|
38
|
+
}) as unknown as typeof fetch;
|
|
39
|
+
const decision = await requestApproval("http://127.0.0.1:1", "tok", { agent: "astro.edge", service: "edge", args: ["vps", "reboot", "--confirm"] }, undefined, 5, fetchImpl);
|
|
40
|
+
expect(decision).toBe("deny");
|
|
41
|
+
expect(seen[0]).toBe("POST http://127.0.0.1:1/approvals");
|
|
42
|
+
expect(seen[1]).toBe("GET http://127.0.0.1:1/approvals/t1");
|
|
43
|
+
expect(polls).toBe(3);
|
|
44
|
+
const failing = vi.fn(async () => new Response("no", { status: 500 })) as unknown as typeof fetch;
|
|
45
|
+
await expect(requestApproval("http://127.0.0.1:1", "tok", { agent: "a", service: "s", args: [] }, undefined, 5, failing)).rejects.toThrow(/500/);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -10,6 +10,83 @@ export const CHILD_AGENT_ENV = AGENT_ENV;
|
|
|
10
10
|
const DEFAULT_USER = "specialist";
|
|
11
11
|
const FIXED_COMMAND_MARKERS = ["specialist-cli", "arcane-cli"];
|
|
12
12
|
|
|
13
|
+
/** Set by a chat bridge (astro-discord) on the specialist child: where to ask an operator before a risky call. */
|
|
14
|
+
export const APPROVAL_URL_ENV = "ASTRO_APPROVAL_URL";
|
|
15
|
+
export const APPROVAL_TOKEN_ENV = "ASTRO_APPROVAL_TOKEN";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calls that interrupt a service, delete data, or change live configuration.
|
|
19
|
+
* Anything carrying `--confirm` is risky by construction; these patterns add
|
|
20
|
+
* the writes that do not need `--confirm` in a terminal session.
|
|
21
|
+
*/
|
|
22
|
+
export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
|
|
23
|
+
arcane: [
|
|
24
|
+
/^(projects|stacks)\s+(up|down|redeploy|restart|stop|delete|remove|rm|destroy|upgrade)\b/,
|
|
25
|
+
/^containers\s+(stop|restart|kill|remove|rm|delete)\b/,
|
|
26
|
+
/^(images|volumes|networks)\s+(prune|remove|rm|delete)\b/,
|
|
27
|
+
/^system\s+(prune|upgrade)\b/,
|
|
28
|
+
/^gitops\s+(sync|delete|update|import)\b/,
|
|
29
|
+
],
|
|
30
|
+
identity: [/^zitadel\s+(DELETE|POST|PUT|PATCH)\b/, /^dmz\s+(deploy-config|pomerium-restart|render|lego-renew)\b/],
|
|
31
|
+
network: [/^unifi\s+policy-(create|update|delete)\b/],
|
|
32
|
+
dns: [/^technitium\s+primary\s+(zone-create|zone-delete|record-add|record-update|record-delete)\b/, /^cloudflare\s+record-(add|update|delete)\b/],
|
|
33
|
+
edge: [/^vps\s+deploy-nginx\b/],
|
|
34
|
+
backup: [/^(vzdump-run|host-backup-run|arcane-backup-run|gcs-run|restic-check-run|restic-restore|restore-clean)\b/],
|
|
35
|
+
proxmox: [/^(set|snapshot-create|start)\b/],
|
|
36
|
+
inference: [/^(nexus-restart|nexus-start|hermes-restart)\b/],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function isRisky(service: string, args: readonly string[]): boolean {
|
|
40
|
+
if (args.includes("--confirm")) return true;
|
|
41
|
+
const joined = args.join(" ");
|
|
42
|
+
return (RISKY[service] ?? []).some((pattern) => pattern.test(joined));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Decision = "approve" | "deny" | "timeout";
|
|
46
|
+
|
|
47
|
+
export interface ApprovalRequest {
|
|
48
|
+
agent: string;
|
|
49
|
+
service: string;
|
|
50
|
+
args: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Ticket {
|
|
54
|
+
id?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface TicketState {
|
|
58
|
+
decision?: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Asks the bridge for an operator decision: POST creates a ticket, then the
|
|
63
|
+
* ticket is polled until it is decided. Polling keeps every HTTP call short.
|
|
64
|
+
*/
|
|
65
|
+
export async function requestApproval(url: string, token: string, request: ApprovalRequest, signal?: AbortSignal, pollMs = 5000, fetchImpl: typeof fetch = fetch): Promise<Decision> {
|
|
66
|
+
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
67
|
+
const created = await fetchImpl(`${url}/approvals`, { method: "POST", headers, body: JSON.stringify(request), signal });
|
|
68
|
+
if (!created.ok) throw new Error(`approval endpoint answered ${created.status}`);
|
|
69
|
+
const ticket = (await created.json()) as Ticket;
|
|
70
|
+
if (typeof ticket.id !== "string") throw new Error("approval endpoint returned no ticket id");
|
|
71
|
+
for (;;) {
|
|
72
|
+
await new Promise<void>((resolve, reject) => {
|
|
73
|
+
const timer = setTimeout(resolve, pollMs);
|
|
74
|
+
signal?.addEventListener(
|
|
75
|
+
"abort",
|
|
76
|
+
() => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
reject(new Error("aborted while waiting for approval"));
|
|
79
|
+
},
|
|
80
|
+
{ once: true },
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
const response = await fetchImpl(`${url}/approvals/${ticket.id}`, { headers, signal });
|
|
84
|
+
if (!response.ok) throw new Error(`approval endpoint answered ${response.status}`);
|
|
85
|
+
const state = (await response.json()) as TicketState;
|
|
86
|
+
if (state.decision === "approve" || state.decision === "deny" || state.decision === "timeout") return state.decision;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
13
90
|
export interface Guard {
|
|
14
91
|
user: string;
|
|
15
92
|
commandMarkers: string[];
|
|
@@ -103,6 +180,13 @@ function registerSpecialistTool(pi: ExtensionAPI, config: SpecialistsConfig, ent
|
|
|
103
180
|
],
|
|
104
181
|
parameters: params,
|
|
105
182
|
async execute(_toolCallId, input, signal) {
|
|
183
|
+
const approvalUrl = process.env[APPROVAL_URL_ENV];
|
|
184
|
+
if (approvalUrl && isRisky(entry.service, input.args)) {
|
|
185
|
+
const decision = await requestApproval(approvalUrl, process.env[APPROVAL_TOKEN_ENV] ?? "", { agent: entry.agent, service: entry.service, args: input.args }, signal);
|
|
186
|
+
if (decision !== "approve") {
|
|
187
|
+
throw new Error(`${entry.service}: the operator ${decision === "deny" ? "denied" : "did not answer the approval request for"} \`${input.args.join(" ")}\`. Report this and stop.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
106
190
|
const result = await pi.exec("sudo", entryArgs(config, entry, input.args), {
|
|
107
191
|
signal,
|
|
108
192
|
timeout: entry.timeoutMs,
|
package/package.json
CHANGED
package/specialists/AGENTS.md
CHANGED
|
@@ -67,6 +67,10 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
67
67
|
6. Host: note fields, `provision`, config file, `specialists.json` entry, target entry script. Document the fields in the homelab `03-cortex-macmini/ROTATION.md` and host facts in `03-cortex-macmini/README.md`.
|
|
68
68
|
7. README in this folder: table row and setup step; root `README.md`: the agent and skill lists.
|
|
69
69
|
|
|
70
|
+
## Chat bridges
|
|
71
|
+
|
|
72
|
+
`extensions/astro-discord/` runs specialists for Discord messages through `runAgent` and passes `ASTRO_APPROVAL_URL`/`ASTRO_APPROVAL_TOKEN` to the child. The gate consults `RISKY` in `extensions/specialist-gate/index.ts` and blocks on a ticket until an owner decides; keep `RISKY` in step with the wrappers when a new interrupting or destructive command appears (every `--confirm` command is covered by construction).
|
|
73
|
+
|
|
70
74
|
## Next
|
|
71
75
|
|
|
72
|
-
All nine specialists exist; `network` writes firewall policies
|
|
76
|
+
All nine specialists exist; `network` writes firewall policies, `identity` deploys Pomerium routes, and Discord access is live. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands), then the Astrogate route.
|
package/specialists/README.md
CHANGED
|
@@ -101,6 +101,16 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
|
|
|
101
101
|
|
|
102
102
|
Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`, `backup -- vzdump-tasks`, `proxmox -- guests`, `inference -- nexus-status`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.
|
|
103
103
|
|
|
104
|
+
## Discord
|
|
105
|
+
|
|
106
|
+
The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
|
|
107
|
+
|
|
108
|
+
- In a configured channel, `dns list the zones` or `@Cortex edge probe the front door` runs the specialist and posts the answer as a reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` lists what you may use; `status` lists running tasks. Long answers arrive as a `.md` attachment.
|
|
109
|
+
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Edit `~/.config/astro-discord/config.json` on Cortex; changes apply to the next message.
|
|
110
|
+
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) 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.
|
|
111
|
+
- Setup, as `cortex`: `node --disable-warning=ExperimentalWarning ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/setup.ts` 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`.
|
|
112
|
+
- 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.
|
|
113
|
+
|
|
104
114
|
## Rotation and operations
|
|
105
115
|
|
|
106
116
|
Rotation steps for every field live in the homelab repository, `03-cortex-macmini/ROTATION.md`. After any change in Vaultwarden run `specialist-cli provision`. Host state and the sudo rules are documented in `03-cortex-macmini/README.md`; the forced-command scripts are tracked under `02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `host/` of the `astronaute77/arcane` repository, `04-nexus-macstudio/system/`, and `00-frontdoor-vps/system/`.
|
package/specialists/lib/repo.ts
CHANGED
|
@@ -30,7 +30,8 @@ export const REPO_HELP = ` git status branch, ahead/behind, ch
|
|
|
30
30
|
git log [n] last n commits (default 10)
|
|
31
31
|
git write <path> <content> write a file inside the checkout (creates folders)
|
|
32
32
|
git commit <message> stage everything and commit
|
|
33
|
-
git push push the branch to origin
|
|
33
|
+
git push push the branch to origin
|
|
34
|
+
git key public key and fingerprint of the deploy key in use`;
|
|
34
35
|
|
|
35
36
|
const TIMEOUT_MS = 240_000;
|
|
36
37
|
|
|
@@ -115,6 +116,16 @@ export async function trackedFile(spec: RepoSpec, rel: string): Promise<string>
|
|
|
115
116
|
|
|
116
117
|
export async function repoCommand(spec: RepoSpec, args: string[]): Promise<number> {
|
|
117
118
|
const [sub, ...rest] = args;
|
|
119
|
+
if (sub === "key") {
|
|
120
|
+
const key = secretPath(spec.service, spec.deployKeyField);
|
|
121
|
+
const [pub, fingerprint] = await Promise.all([
|
|
122
|
+
run("/usr/bin/ssh-keygen", ["-y", "-f", key], { timeoutMs: 10_000 }),
|
|
123
|
+
run("/usr/bin/ssh-keygen", ["-l", "-f", key], { timeoutMs: 10_000 }),
|
|
124
|
+
]);
|
|
125
|
+
if (pub.code !== 0) throw new ServiceError(`deploy key unreadable: ${pub.stderr.trim()}`);
|
|
126
|
+
printJson({ publicKey: pub.stdout.trim(), fingerprint: fingerprint.stdout.trim() });
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
118
129
|
const dir = await ensureCheckout(spec);
|
|
119
130
|
switch (sub) {
|
|
120
131
|
case "status":
|