talon-agent 3.28.1 → 3.30.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 +12 -9
- package/package.json +1 -1
- package/src/bootstrap.ts +22 -5
- package/src/core/doctor.ts +41 -0
- package/src/core/plugin/builtins.ts +81 -5
- package/src/core/plugin/native-runtimes.ts +87 -0
- package/src/core/plugin/provision-journal.ts +172 -0
- package/src/core/plugin/provision.ts +276 -0
- package/src/frontend/discord/commands/admin.ts +5 -0
- package/src/frontend/telegram/commands/admin.ts +5 -0
- package/src/frontend/telegram/handlers/access.ts +28 -2
- package/src/frontend/telegram/handlers/state.ts +2 -0
- package/src/frontend/telegram/index.ts +1 -0
- package/src/frontend/whatsapp/index.ts +19 -5
- package/src/frontend/whatsapp/pairing.ts +19 -0
- package/src/plugins/github/index.ts +15 -17
- package/src/plugins/github/provision.ts +172 -0
- package/src/plugins/mempalace/index.ts +17 -3
- package/src/plugins/mempalace/provision.ts +667 -0
- package/src/plugins/playwright/provision.ts +322 -0
- package/src/util/config.ts +31 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared provisioning seam for built-in plugins with native runtimes.
|
|
3
|
+
*
|
|
4
|
+
* A "native" plugin (MemPalace's Python venv, Playwright's browser
|
|
5
|
+
* binaries, GitHub's Docker image) depends on an artifact Talon does not
|
|
6
|
+
* ship in its npm tarball. This module is the common ground those
|
|
7
|
+
* provisioners stand on: a persisted state file with failure backoff so
|
|
8
|
+
* a broken network can't turn boot into a retry storm, a never-throws
|
|
9
|
+
* exec runner with hard timeouts, cross-OS Python discovery, and a
|
|
10
|
+
* dependency-free semver compare.
|
|
11
|
+
*
|
|
12
|
+
* The contract every provisioner honors: an existing working install is
|
|
13
|
+
* NEVER made worse. Upgrades that fail keep the current version running;
|
|
14
|
+
* destructive healing (recreating a venv) is reserved for environments
|
|
15
|
+
* that are already broken AND owned by Talon.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
19
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** What a provisioning pass concluded — pure data, renderers decide presentation. */
|
|
23
|
+
export interface ProvisionOutcome {
|
|
24
|
+
/**
|
|
25
|
+
* ready — the runtime is usable right now (possibly with warnings).
|
|
26
|
+
* degraded — usable, but not at the pinned version (upgrade failed or deferred).
|
|
27
|
+
* failed — not usable; the plugin will not come up until resolved.
|
|
28
|
+
* skipped — provisioning disabled or not applicable (e.g. endpoint mode).
|
|
29
|
+
*/
|
|
30
|
+
status: "ready" | "degraded" | "failed" | "skipped";
|
|
31
|
+
/** Installed runtime version, when it could be determined. */
|
|
32
|
+
version?: string;
|
|
33
|
+
/** Install flavor (e.g. "managed-venv", "uv-tool", "pipx", "system"). */
|
|
34
|
+
kind?: string;
|
|
35
|
+
/** Mutations performed this pass ("created venv", "installed 3.8.0", …). */
|
|
36
|
+
actions: string[];
|
|
37
|
+
/** Advisory messages for the operator (upgrade hints, backoff notices). */
|
|
38
|
+
warnings: string[];
|
|
39
|
+
/** Terminal error detail when status is "failed". */
|
|
40
|
+
error?: string;
|
|
41
|
+
/**
|
|
42
|
+
* A reconcile task the caller may run without blocking boot (e.g. a
|
|
43
|
+
* version upgrade while a working older install keeps serving). The
|
|
44
|
+
* caller decides whether to await it or fire-and-forget with logging.
|
|
45
|
+
*/
|
|
46
|
+
background?: () => Promise<ProvisionOutcome>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Persisted per-provisioner state (one small JSON file under ~/.talon/data/). */
|
|
50
|
+
export interface ProvisionState {
|
|
51
|
+
/** The version target of the last attempt — a pin change resets backoff. */
|
|
52
|
+
pin?: string;
|
|
53
|
+
installedVersion?: string;
|
|
54
|
+
lastSuccessAt?: string;
|
|
55
|
+
lastFailureAt?: string;
|
|
56
|
+
failureCount?: number;
|
|
57
|
+
lastError?: string;
|
|
58
|
+
/** One-time data migrations already applied, by name. */
|
|
59
|
+
migrations?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function loadProvisionState(path: string): ProvisionState {
|
|
63
|
+
try {
|
|
64
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
|
|
65
|
+
return parsed && typeof parsed === "object"
|
|
66
|
+
? (parsed as ProvisionState)
|
|
67
|
+
: {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function saveProvisionState(path: string, state: ProvisionState): void {
|
|
74
|
+
try {
|
|
75
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
76
|
+
writeFileSync(path, JSON.stringify(state, null, 2));
|
|
77
|
+
} catch {
|
|
78
|
+
/* state is an optimization (backoff, migration ledger) — never fatal */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Base retry delay after the first failure. */
|
|
83
|
+
const BACKOFF_BASE_MS = 5 * 60_000;
|
|
84
|
+
/** Retry delay ceiling. */
|
|
85
|
+
const BACKOFF_MAX_MS = 6 * 60 * 60_000;
|
|
86
|
+
|
|
87
|
+
/** Exponential backoff: 5min, 10min, 20min … capped at 6h. */
|
|
88
|
+
export function provisionBackoffMs(failureCount: number): number {
|
|
89
|
+
const exp = Math.max(0, Math.min(failureCount - 1, 30));
|
|
90
|
+
return Math.min(BACKOFF_BASE_MS * 2 ** exp, BACKOFF_MAX_MS);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a new install/upgrade attempt is due. A changed pin always
|
|
95
|
+
* re-arms immediately — the operator (or a Talon upgrade) asked for a
|
|
96
|
+
* different version, so stale failures shouldn't gate it.
|
|
97
|
+
*/
|
|
98
|
+
export function shouldAttempt(
|
|
99
|
+
state: ProvisionState,
|
|
100
|
+
pin: string,
|
|
101
|
+
nowMs: number,
|
|
102
|
+
): boolean {
|
|
103
|
+
if (!state.lastFailureAt) return true;
|
|
104
|
+
if (state.pin !== pin) return true;
|
|
105
|
+
const last = Date.parse(state.lastFailureAt);
|
|
106
|
+
if (Number.isNaN(last)) return true;
|
|
107
|
+
return nowMs - last >= provisionBackoffMs(state.failureCount ?? 1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ExecResult {
|
|
111
|
+
ok: boolean;
|
|
112
|
+
code: number | null;
|
|
113
|
+
stdout: string;
|
|
114
|
+
stderr: string;
|
|
115
|
+
/** Spawn-level failure (ENOENT, EACCES, timeout kill), when there was one. */
|
|
116
|
+
error?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type ExecFn = (
|
|
120
|
+
cmd: string,
|
|
121
|
+
args: readonly string[],
|
|
122
|
+
opts: { timeoutMs: number; env?: Record<string, string> },
|
|
123
|
+
) => Promise<ExecResult>;
|
|
124
|
+
|
|
125
|
+
/** Default exec: never throws, hard-kills on timeout, captures both streams. */
|
|
126
|
+
export const runStep: ExecFn = (cmd, args, opts) =>
|
|
127
|
+
new Promise((resolvePromise) => {
|
|
128
|
+
execFileCb(
|
|
129
|
+
cmd,
|
|
130
|
+
[...args],
|
|
131
|
+
{
|
|
132
|
+
timeout: opts.timeoutMs,
|
|
133
|
+
killSignal: "SIGKILL",
|
|
134
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
135
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
},
|
|
138
|
+
(err, stdout, stderr) => {
|
|
139
|
+
const out = stdout?.toString() ?? "";
|
|
140
|
+
const errOut = stderr?.toString() ?? "";
|
|
141
|
+
if (!err) {
|
|
142
|
+
resolvePromise({ ok: true, code: 0, stdout: out, stderr: errOut });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const spawnErr = err as NodeJS.ErrnoException & {
|
|
146
|
+
killed?: boolean;
|
|
147
|
+
code?: number | string;
|
|
148
|
+
};
|
|
149
|
+
const code = typeof spawnErr.code === "number" ? spawnErr.code : null;
|
|
150
|
+
const error =
|
|
151
|
+
typeof spawnErr.code === "string"
|
|
152
|
+
? spawnErr.code
|
|
153
|
+
: spawnErr.killed
|
|
154
|
+
? `timed out after ${Math.round(opts.timeoutMs / 1000)}s`
|
|
155
|
+
: undefined;
|
|
156
|
+
resolvePromise({ ok: false, code, stdout: out, stderr: errOut, error });
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Numeric dotted-version compare ("3.8.0" vs "3.10.1"): negative when a < b,
|
|
163
|
+
* 0 when equal, positive when a > b. Non-numeric segments compare as 0,
|
|
164
|
+
* which is the safe reading for pre-release suffixes here — a fuzzy match
|
|
165
|
+
* triggers at worst a no-op reinstall, never a skipped one.
|
|
166
|
+
*/
|
|
167
|
+
export function compareVersions(a: string, b: string): number {
|
|
168
|
+
const pa = a.split(".").map((s) => parseInt(s, 10) || 0);
|
|
169
|
+
const pb = b.split(".").map((s) => parseInt(s, 10) || 0);
|
|
170
|
+
const len = Math.max(pa.length, pb.length);
|
|
171
|
+
for (let i = 0; i < len; i++) {
|
|
172
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
173
|
+
if (diff !== 0) return diff;
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Venv interpreter path for a venv root (bin/python vs Scripts/python.exe). */
|
|
179
|
+
export function venvPython(venvDir: string, platform: NodeJS.Platform): string {
|
|
180
|
+
return platform === "win32"
|
|
181
|
+
? resolve(venvDir, "Scripts", "python.exe")
|
|
182
|
+
: resolve(venvDir, "bin", "python");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface BasePython {
|
|
186
|
+
command: string;
|
|
187
|
+
args: string[];
|
|
188
|
+
version: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find a base interpreter able to seed a venv. Order matters per OS: the
|
|
193
|
+
* `py` launcher is the canonical entry on Windows (PATH pythons there are
|
|
194
|
+
* often the Store stub), python3 the canonical one elsewhere.
|
|
195
|
+
*/
|
|
196
|
+
export async function findBasePython(
|
|
197
|
+
exec: ExecFn,
|
|
198
|
+
platform: NodeJS.Platform,
|
|
199
|
+
min: { major: number; minor: number },
|
|
200
|
+
): Promise<BasePython | undefined> {
|
|
201
|
+
const candidates: Array<[string, string[]]> =
|
|
202
|
+
platform === "win32"
|
|
203
|
+
? [
|
|
204
|
+
["py", ["-3"]],
|
|
205
|
+
["python", []],
|
|
206
|
+
["python3", []],
|
|
207
|
+
]
|
|
208
|
+
: [
|
|
209
|
+
["python3", []],
|
|
210
|
+
["python", []],
|
|
211
|
+
];
|
|
212
|
+
for (const [command, args] of candidates) {
|
|
213
|
+
const probe = await exec(
|
|
214
|
+
command,
|
|
215
|
+
[...args, "-c", "import sys; print('%d.%d' % sys.version_info[:2])"],
|
|
216
|
+
{ timeoutMs: 20_000 },
|
|
217
|
+
);
|
|
218
|
+
if (!probe.ok) continue;
|
|
219
|
+
const version = probe.stdout.trim();
|
|
220
|
+
const [major, minor] = version.split(".").map((s) => parseInt(s, 10));
|
|
221
|
+
if (
|
|
222
|
+
Number.isFinite(major) &&
|
|
223
|
+
(major > min.major || (major === min.major && (minor ?? 0) >= min.minor))
|
|
224
|
+
) {
|
|
225
|
+
return { command, args, version };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Expand a leading `~/` (or bare `~`) to the home directory. */
|
|
232
|
+
export function expandHome(path: string, home: string): string {
|
|
233
|
+
if (path === "~") return home;
|
|
234
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
235
|
+
return resolve(home, path.slice(2));
|
|
236
|
+
}
|
|
237
|
+
return path;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Human-readable failure detail from an exec result (spawn error, last stderr line, or exit code). */
|
|
241
|
+
export function failDetail(result: ExecResult): string {
|
|
242
|
+
// An empty stderr yields "" from pop(), which is not nullish — treat
|
|
243
|
+
// it as absent so the exit-code fallback actually fires.
|
|
244
|
+
const tail = result.stderr.trim().split("\n").pop()?.slice(0, 300);
|
|
245
|
+
return result.error ?? (tail || `exit ${result.code ?? "?"}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Record a successful pass: clears the failure streak, persists. */
|
|
249
|
+
export function markProvisionSuccess(
|
|
250
|
+
statePath: string,
|
|
251
|
+
state: ProvisionState,
|
|
252
|
+
pin: string,
|
|
253
|
+
nowMs: number,
|
|
254
|
+
): void {
|
|
255
|
+
state.pin = pin;
|
|
256
|
+
state.lastSuccessAt = new Date(nowMs).toISOString();
|
|
257
|
+
state.failureCount = 0;
|
|
258
|
+
state.lastFailureAt = undefined;
|
|
259
|
+
state.lastError = undefined;
|
|
260
|
+
saveProvisionState(statePath, state);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Record a failed pass: bumps the failure streak for backoff, persists. */
|
|
264
|
+
export function markProvisionFailure(
|
|
265
|
+
statePath: string,
|
|
266
|
+
state: ProvisionState,
|
|
267
|
+
pin: string,
|
|
268
|
+
error: string,
|
|
269
|
+
nowMs: number,
|
|
270
|
+
): void {
|
|
271
|
+
state.pin = pin;
|
|
272
|
+
state.lastFailureAt = new Date(nowMs).toISOString();
|
|
273
|
+
state.failureCount = (state.failureCount ?? 0) + 1;
|
|
274
|
+
state.lastError = error;
|
|
275
|
+
saveProvisionState(statePath, state);
|
|
276
|
+
}
|
|
@@ -237,6 +237,11 @@ export async function handleUpdate(
|
|
|
237
237
|
await edit(
|
|
238
238
|
`✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
|
|
239
239
|
);
|
|
240
|
+
// The successor documents any provisioning changes (plugin runtime
|
|
241
|
+
// upgrades, migrations) back to this channel once it's up.
|
|
242
|
+
const { armProvisionReport } =
|
|
243
|
+
await import("../../../core/plugin/provision-journal.js");
|
|
244
|
+
armProvisionReport("discord", String(i.channelId));
|
|
240
245
|
respawnSelf("discord /update");
|
|
241
246
|
})
|
|
242
247
|
.catch(async (err: unknown) => {
|
|
@@ -214,6 +214,11 @@ export function registerAdminCommands(
|
|
|
214
214
|
await edit(
|
|
215
215
|
`✅ Updated <code>${escapeHtml(res.before ?? "?")}</code> → <code>${escapeHtml(res.after ?? "?")}</code>. ♻️ Restarting…`,
|
|
216
216
|
);
|
|
217
|
+
// The successor documents any provisioning changes (plugin
|
|
218
|
+
// runtime upgrades, migrations) back to this chat once it's up.
|
|
219
|
+
const { armProvisionReport } =
|
|
220
|
+
await import("../../../core/plugin/provision-journal.js");
|
|
221
|
+
armProvisionReport("telegram", String(ctx.chat.id));
|
|
217
222
|
respawnSelf("telegram /update");
|
|
218
223
|
})
|
|
219
224
|
.catch(async (err: unknown) => {
|
|
@@ -41,14 +41,31 @@ export function trackDmUser(
|
|
|
41
41
|
|
|
42
42
|
export function setAccessControl(cfg: {
|
|
43
43
|
allowedUsers?: number[];
|
|
44
|
+
blockedUsers?: number[];
|
|
44
45
|
adminUserId?: number;
|
|
45
46
|
}): void {
|
|
46
47
|
accessConfig.allowedUserIds = cfg.allowedUsers?.length
|
|
47
48
|
? new Set(cfg.allowedUsers)
|
|
48
49
|
: null;
|
|
50
|
+
accessConfig.blockedUserIds = cfg.blockedUsers?.length
|
|
51
|
+
? new Set(cfg.blockedUsers)
|
|
52
|
+
: null;
|
|
49
53
|
accessConfig.adminId = cfg.adminUserId ?? 0;
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Blocked senders are dropped before any other check, in silence.
|
|
58
|
+
*
|
|
59
|
+
* The whitelist already stops an unknown sender being *acted on*, but it still
|
|
60
|
+
* answers them with a warning and pings the admin. For a spammer or a
|
|
61
|
+
* prompt-injection sender that reply is the payoff — it confirms a live bot is
|
|
62
|
+
* reading — and the admin ping is the cost. Blocking removes both.
|
|
63
|
+
*/
|
|
64
|
+
function isBlocked(senderId: number | undefined): boolean {
|
|
65
|
+
if (!accessConfig.blockedUserIds || senderId === undefined) return false;
|
|
66
|
+
return accessConfig.blockedUserIds.has(senderId);
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/**
|
|
53
70
|
* Check if a DM user is allowed. Returns true if no whitelist is set.
|
|
54
71
|
*/
|
|
@@ -114,15 +131,24 @@ export function shouldHandleInGroup(ctx: Context): boolean {
|
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
/**
|
|
117
|
-
* Full access check: DM whitelist + group admin membership.
|
|
134
|
+
* Full access check: denylist, then DM whitelist + group admin membership.
|
|
118
135
|
* Returns true if the message should be processed.
|
|
119
|
-
* Warns unauthorized users and notifies the admin
|
|
136
|
+
* Warns unauthorized users and notifies the admin — except blocked users,
|
|
137
|
+
* who are dropped silently.
|
|
120
138
|
*/
|
|
121
139
|
export async function isAccessAllowed(
|
|
122
140
|
ctx: Context,
|
|
123
141
|
bot: Bot,
|
|
124
142
|
): Promise<boolean> {
|
|
125
143
|
if (!ctx.chat) return false;
|
|
144
|
+
|
|
145
|
+
// Denylist wins over everything, including the group path: a blocked user
|
|
146
|
+
// gets no reply and generates no notification, anywhere.
|
|
147
|
+
if (isBlocked(ctx.from?.id)) {
|
|
148
|
+
log("users", `Dropped message from blocked user [id:${ctx.from?.id}]`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
126
152
|
const isGroup = ctx.chat.type === "group" || ctx.chat.type === "supergroup";
|
|
127
153
|
|
|
128
154
|
if (!isGroup) {
|
|
@@ -20,9 +20,11 @@ export const KNOWN_DM_USERS_CAP = 10_000;
|
|
|
20
20
|
/** Reassignable access config — holder object so setAccessControl can mutate. */
|
|
21
21
|
export const accessConfig: {
|
|
22
22
|
allowedUserIds: Set<number> | null; // null = no whitelist (allow all)
|
|
23
|
+
blockedUserIds: Set<number> | null; // null = no denylist
|
|
23
24
|
adminId: number;
|
|
24
25
|
} = {
|
|
25
26
|
allowedUserIds: null,
|
|
27
|
+
blockedUserIds: null,
|
|
26
28
|
adminId: 0,
|
|
27
29
|
};
|
|
28
30
|
|
|
@@ -53,7 +53,7 @@ import {
|
|
|
53
53
|
seedMessageStore,
|
|
54
54
|
} from "./message-store.js";
|
|
55
55
|
import { runTurnWithRecovery, shouldReplyToCatchUp } from "./turn-recovery.js";
|
|
56
|
-
import { classifyClose, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
56
|
+
import { classifyClose, isPaired, REPLACED_BACKOFF_MS } from "./pairing.js";
|
|
57
57
|
import { isManualPairingActive, onPairingComplete } from "./pairing-lock.js";
|
|
58
58
|
import { flushAuthWrites, useAtomicAuthState } from "./auth-state.js";
|
|
59
59
|
import { makeWaLogger } from "./wa-logger.js";
|
|
@@ -527,11 +527,15 @@ export function createWhatsAppFrontend(
|
|
|
527
527
|
case "logged-out":
|
|
528
528
|
return resolve("logged-out");
|
|
529
529
|
default:
|
|
530
|
-
// A socket that died without ever
|
|
531
|
-
// window that expired — reconnecting would just open QR
|
|
530
|
+
// A socket that died without ever completing a login is a
|
|
531
|
+
// pairing window that expired — reconnecting would just open QR
|
|
532
532
|
// session after QR session against WhatsApp's servers.
|
|
533
533
|
// Park instead; /whatsapp pair opens the next one.
|
|
534
|
-
|
|
534
|
+
//
|
|
535
|
+
// Test with isPaired, not creds.registered: a QR-linked session
|
|
536
|
+
// never sets that flag, so the flag alone parks a healthy
|
|
537
|
+
// session on its first ordinary disconnect.
|
|
538
|
+
if (!isPaired(state.creds)) return resolve("unpaired");
|
|
535
539
|
log(
|
|
536
540
|
"whatsapp",
|
|
537
541
|
`Connection closed (code ${code ?? "?"}) — reconnecting`,
|
|
@@ -556,7 +560,17 @@ export function createWhatsAppFrontend(
|
|
|
556
560
|
resolvePath(dirs.whatsappAuth, "creds.json"),
|
|
557
561
|
"utf-8",
|
|
558
562
|
);
|
|
559
|
-
|
|
563
|
+
// Same trap as above: waiting on `registered` alone means a QR
|
|
564
|
+
// re-link can never end the park, because that flag stays false.
|
|
565
|
+
if (
|
|
566
|
+
isPaired(
|
|
567
|
+
JSON.parse(raw) as {
|
|
568
|
+
registered?: boolean;
|
|
569
|
+
me?: { id?: string } | null;
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
return;
|
|
560
574
|
} catch {
|
|
561
575
|
/* no creds yet — stay parked */
|
|
562
576
|
}
|
|
@@ -41,5 +41,24 @@ export function classifyClose(
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Has this auth state ever completed a login?
|
|
46
|
+
*
|
|
47
|
+
* `creds.registered` looks like the obvious test and is a trap: Baileys only
|
|
48
|
+
* sets it on the pairing-CODE path. A session linked by scanning the QR stays
|
|
49
|
+
* `registered: false` forever while being completely authenticated — it has
|
|
50
|
+
* `me`, `account`, app-state keys, and it sends and receives normally.
|
|
51
|
+
*
|
|
52
|
+
* Reading the flag alone therefore cannot tell a live QR session apart from a
|
|
53
|
+
* pairing window that expired without anyone scanning it, which is the
|
|
54
|
+
* distinction the reconnect loop actually needs.
|
|
55
|
+
*/
|
|
56
|
+
export function isPaired(creds: {
|
|
57
|
+
registered?: boolean;
|
|
58
|
+
me?: { id?: string } | null;
|
|
59
|
+
}): boolean {
|
|
60
|
+
return Boolean(creds.registered || creds.me?.id);
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
/** How long to sit out after a 440 — another client owns the session. */
|
|
45
64
|
export const REPLACED_BACKOFF_MS = 60_000;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
15
|
import type { TalonPlugin } from "../../core/plugin/types.js";
|
|
16
16
|
import { log, logWarn } from "../../util/log.js";
|
|
17
|
+
import { githubMcpImageRef } from "./provision.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Resolve a GitHub personal access token.
|
|
@@ -34,8 +35,12 @@ function resolveToken(configToken?: string): string | undefined {
|
|
|
34
35
|
}
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export function createGitHubPlugin(config: {
|
|
38
|
+
export function createGitHubPlugin(config: {
|
|
39
|
+
token?: string;
|
|
40
|
+
imageTag?: string;
|
|
41
|
+
}): TalonPlugin {
|
|
38
42
|
const token = resolveToken(config.token);
|
|
43
|
+
const image = githubMcpImageRef(config.imageTag);
|
|
39
44
|
|
|
40
45
|
return {
|
|
41
46
|
name: "github",
|
|
@@ -44,14 +49,7 @@ export function createGitHubPlugin(config: { token?: string }): TalonPlugin {
|
|
|
44
49
|
|
|
45
50
|
mcpServer: {
|
|
46
51
|
command: "docker",
|
|
47
|
-
args: [
|
|
48
|
-
"run",
|
|
49
|
-
"--rm",
|
|
50
|
-
"-i",
|
|
51
|
-
"-e",
|
|
52
|
-
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
53
|
-
"ghcr.io/github/github-mcp-server",
|
|
54
|
-
],
|
|
52
|
+
args: ["run", "--rm", "-i", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", image],
|
|
55
53
|
},
|
|
56
54
|
|
|
57
55
|
validateConfig() {
|
|
@@ -79,18 +77,18 @@ export function createGitHubPlugin(config: { token?: string }): TalonPlugin {
|
|
|
79
77
|
},
|
|
80
78
|
|
|
81
79
|
async init() {
|
|
82
|
-
// Verify the Docker image exists locally
|
|
80
|
+
// Verify the pinned Docker image exists locally (the provisioner
|
|
81
|
+
// pulls it in the background when absent).
|
|
83
82
|
try {
|
|
84
|
-
execFileSync(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
);
|
|
89
|
-
log("github", "Docker image verified");
|
|
83
|
+
execFileSync("docker", ["image", "inspect", image], {
|
|
84
|
+
timeout: 10_000,
|
|
85
|
+
stdio: "pipe",
|
|
86
|
+
});
|
|
87
|
+
log("github", `Docker image verified (${image})`);
|
|
90
88
|
} catch {
|
|
91
89
|
logWarn(
|
|
92
90
|
"github",
|
|
93
|
-
|
|
91
|
+
`Docker image ${image} not present yet — pulling in background; docker pulls on first use as fallback`,
|
|
94
92
|
);
|
|
95
93
|
}
|
|
96
94
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub MCP provisioner — pinned Docker image, pulled ahead of use.
|
|
3
|
+
*
|
|
4
|
+
* `docker run` on an absent image blocks the first tool call on a
|
|
5
|
+
* network pull; a floating `latest` means the server silently changes
|
|
6
|
+
* under a deployment. Pin a known-good tag and front-load the pull at
|
|
7
|
+
* boot (in the background — docker itself remains the fallback path,
|
|
8
|
+
* so a slow or failed pull degrades to today's behavior, never worse).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { DoctorCheck } from "../../core/doctor.js";
|
|
13
|
+
import {
|
|
14
|
+
failDetail,
|
|
15
|
+
loadProvisionState,
|
|
16
|
+
markProvisionFailure,
|
|
17
|
+
markProvisionSuccess,
|
|
18
|
+
runStep,
|
|
19
|
+
shouldAttempt,
|
|
20
|
+
type ExecFn,
|
|
21
|
+
type ProvisionOutcome,
|
|
22
|
+
} from "../../core/plugin/provision.js";
|
|
23
|
+
import { dirs } from "../../util/paths.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The github-mcp-server image tag Talon runs. Bump deliberately, with
|
|
27
|
+
* the canary workflow green — see .github/workflows/native-provision.yml.
|
|
28
|
+
*/
|
|
29
|
+
export const GITHUB_MCP_PINNED_TAG = "v1.11.0";
|
|
30
|
+
|
|
31
|
+
const GITHUB_MCP_IMAGE = "ghcr.io/github/github-mcp-server";
|
|
32
|
+
|
|
33
|
+
const PULL_TIMEOUT_MS = 600_000;
|
|
34
|
+
const INSPECT_TIMEOUT_MS = 20_000;
|
|
35
|
+
|
|
36
|
+
export function githubMcpImageRef(imageTag?: string): string {
|
|
37
|
+
return `${GITHUB_MCP_IMAGE}:${imageTag ?? GITHUB_MCP_PINNED_TAG}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The `github` config section this module reads. */
|
|
41
|
+
export interface GithubSection {
|
|
42
|
+
imageTag?: string;
|
|
43
|
+
autoProvision?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface GithubProvisionDeps {
|
|
47
|
+
exec?: ExecFn;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
statePath?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function provisionGithubMcp(
|
|
53
|
+
section: GithubSection,
|
|
54
|
+
deps: GithubProvisionDeps = {},
|
|
55
|
+
): Promise<ProvisionOutcome> {
|
|
56
|
+
const exec = deps.exec ?? runStep;
|
|
57
|
+
const now = deps.now ?? Date.now;
|
|
58
|
+
const image = githubMcpImageRef(section.imageTag);
|
|
59
|
+
|
|
60
|
+
if (section.autoProvision === false) {
|
|
61
|
+
return { status: "skipped", kind: "docker", actions: [], warnings: [] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inspected = await exec("docker", ["image", "inspect", image], {
|
|
65
|
+
timeoutMs: INSPECT_TIMEOUT_MS,
|
|
66
|
+
});
|
|
67
|
+
if (inspected.ok) {
|
|
68
|
+
return {
|
|
69
|
+
status: "ready",
|
|
70
|
+
version: image,
|
|
71
|
+
kind: "docker",
|
|
72
|
+
actions: [],
|
|
73
|
+
warnings: [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (inspected.error === "ENOENT") {
|
|
77
|
+
// No docker at all — validateConfig already reports this as the
|
|
78
|
+
// blocking error; provisioning has nothing to add.
|
|
79
|
+
return {
|
|
80
|
+
status: "failed",
|
|
81
|
+
kind: "docker",
|
|
82
|
+
actions: [],
|
|
83
|
+
warnings: [],
|
|
84
|
+
error: "docker not found",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const statePath = deps.statePath ?? join(dirs.data, "github-provision.json");
|
|
89
|
+
const state = loadProvisionState(statePath);
|
|
90
|
+
if (!shouldAttempt(state, image, now())) {
|
|
91
|
+
return {
|
|
92
|
+
status: "degraded",
|
|
93
|
+
version: image,
|
|
94
|
+
kind: "docker",
|
|
95
|
+
actions: [],
|
|
96
|
+
warnings: [
|
|
97
|
+
`image pull previously failed (${state.lastError ?? "unknown"}); docker will pull on first use — retrying with backoff`,
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
status: "degraded",
|
|
104
|
+
version: image,
|
|
105
|
+
kind: "docker",
|
|
106
|
+
actions: [],
|
|
107
|
+
warnings: [`pulling ${image} in the background`],
|
|
108
|
+
background: async () => {
|
|
109
|
+
const pulled = await exec("docker", ["pull", image], {
|
|
110
|
+
timeoutMs: PULL_TIMEOUT_MS,
|
|
111
|
+
});
|
|
112
|
+
if (pulled.ok) {
|
|
113
|
+
markProvisionSuccess(statePath, state, image, now());
|
|
114
|
+
return {
|
|
115
|
+
status: "ready",
|
|
116
|
+
version: image,
|
|
117
|
+
kind: "docker",
|
|
118
|
+
actions: [`pulled ${image}`],
|
|
119
|
+
warnings: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const detail = failDetail(pulled);
|
|
123
|
+
markProvisionFailure(statePath, state, image, detail, now());
|
|
124
|
+
return {
|
|
125
|
+
status: "degraded",
|
|
126
|
+
version: image,
|
|
127
|
+
kind: "docker",
|
|
128
|
+
actions: [],
|
|
129
|
+
warnings: [
|
|
130
|
+
`image pull failed (${detail}) — docker will pull on first use`,
|
|
131
|
+
],
|
|
132
|
+
error: detail,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Read-only doctor inspection: docker reachable, pinned image present. */
|
|
139
|
+
export async function inspectGithub(
|
|
140
|
+
section: GithubSection,
|
|
141
|
+
deps: GithubProvisionDeps = {},
|
|
142
|
+
): Promise<DoctorCheck[]> {
|
|
143
|
+
const exec = deps.exec ?? runStep;
|
|
144
|
+
const image = githubMcpImageRef(section.imageTag);
|
|
145
|
+
const inspected = await exec("docker", ["image", "inspect", image], {
|
|
146
|
+
timeoutMs: INSPECT_TIMEOUT_MS,
|
|
147
|
+
});
|
|
148
|
+
if (inspected.ok) {
|
|
149
|
+
return [{ label: `GitHub MCP image ${image}`, status: "ok" }];
|
|
150
|
+
}
|
|
151
|
+
if (inspected.error === "ENOENT") {
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
label: "GitHub MCP: docker not found",
|
|
155
|
+
status: "fail",
|
|
156
|
+
detail: "the GitHub MCP server runs as a Docker image",
|
|
157
|
+
issue: true,
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
label: `GitHub MCP image not pulled (${image})`,
|
|
164
|
+
status: "warn",
|
|
165
|
+
detail:
|
|
166
|
+
section.autoProvision === false
|
|
167
|
+
? `automatic pull disabled (github.autoProvision: false) — run: docker pull ${image}`
|
|
168
|
+
: "pulls in the background at next talon start",
|
|
169
|
+
issue: true,
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
}
|