@synap-core/eve 0.1.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 +251 -0
- package/dist/add-R6RGUYER.js +22 -0
- package/dist/add-R6RGUYER.js.map +1 -0
- package/dist/auth-7FFZNARZ-4OIRGZ7R.js +31 -0
- package/dist/auth-7FFZNARZ-4OIRGZ7R.js.map +1 -0
- package/dist/chunk-22RIHU4J.js +975 -0
- package/dist/chunk-22RIHU4J.js.map +1 -0
- package/dist/chunk-23XQDB7K.js +1773 -0
- package/dist/chunk-23XQDB7K.js.map +1 -0
- package/dist/chunk-5TBV2VHZ.js +6067 -0
- package/dist/chunk-5TBV2VHZ.js.map +1 -0
- package/dist/chunk-D7P3IYYR.js +44 -0
- package/dist/chunk-D7P3IYYR.js.map +1 -0
- package/dist/chunk-EHPC7CKE.js +757 -0
- package/dist/chunk-EHPC7CKE.js.map +1 -0
- package/dist/chunk-FAE5UZFE.js +17 -0
- package/dist/chunk-FAE5UZFE.js.map +1 -0
- package/dist/chunk-HRF6TNXX.js +53 -0
- package/dist/chunk-HRF6TNXX.js.map +1 -0
- package/dist/chunk-HVJEW7TU.js +495 -0
- package/dist/chunk-HVJEW7TU.js.map +1 -0
- package/dist/chunk-JO5H5G6U.js +1529 -0
- package/dist/chunk-JO5H5G6U.js.map +1 -0
- package/dist/chunk-MUEQNBE6.js +1140 -0
- package/dist/chunk-MUEQNBE6.js.map +1 -0
- package/dist/chunk-O5WTCVMZ.js +3391 -0
- package/dist/chunk-O5WTCVMZ.js.map +1 -0
- package/dist/chunk-ROKIQIEX.js +65 -0
- package/dist/chunk-ROKIQIEX.js.map +1 -0
- package/dist/chunk-T7GCAJK5.js +4895 -0
- package/dist/chunk-T7GCAJK5.js.map +1 -0
- package/dist/chunk-UOQWJ3S4.js +138 -0
- package/dist/chunk-UOQWJ3S4.js.map +1 -0
- package/dist/chunk-YQWV5W4U.js +3479 -0
- package/dist/chunk-YQWV5W4U.js.map +1 -0
- package/dist/chunk-ZPDNZRVW.js +272 -0
- package/dist/chunk-ZPDNZRVW.js.map +1 -0
- package/dist/config-store-KTOWIKAS-XVZHNPKW.js +11 -0
- package/dist/config-store-KTOWIKAS-XVZHNPKW.js.map +1 -0
- package/dist/dist-2VRFSXMO.js +40 -0
- package/dist/dist-2VRFSXMO.js.map +1 -0
- package/dist/dist-C2QB4IKT.js +340 -0
- package/dist/dist-C2QB4IKT.js.map +1 -0
- package/dist/dist-DEIUVZ57.js +299 -0
- package/dist/dist-DEIUVZ57.js.map +1 -0
- package/dist/dist-DSKHGECB.js +36 -0
- package/dist/dist-DSKHGECB.js.map +1 -0
- package/dist/dist-GN2DN7CI.js +95 -0
- package/dist/dist-GN2DN7CI.js.map +1 -0
- package/dist/dist-TZL4XW2R.js +58 -0
- package/dist/dist-TZL4XW2R.js.map +1 -0
- package/dist/dist-ZP2APOYV.js +53 -0
- package/dist/dist-ZP2APOYV.js.map +1 -0
- package/dist/index.js +15083 -0
- package/dist/index.js.map +1 -0
- package/dist/operational-5RT6AZJI-ZEMPD32Q.js +12 -0
- package/dist/operational-5RT6AZJI-ZEMPD32Q.js.map +1 -0
- package/dist/reconcile-A4I3Z7VX-GQMEN4GC.js +12 -0
- package/dist/reconcile-A4I3Z7VX-GQMEN4GC.js.map +1 -0
- package/dist/rsshub-GOJBTNOD-UNNVKBFL.js +18 -0
- package/dist/rsshub-GOJBTNOD-UNNVKBFL.js.map +1 -0
- package/dist/synap-image-install-X36REYQ5-UGO4MAOP.js +17 -0
- package/dist/synap-image-install-X36REYQ5-UGO4MAOP.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,1529 @@
|
|
|
1
|
+
import { createRequire as __eveCreateRequire } from 'module'; const require = __eveCreateRequire(import.meta.url);
|
|
2
|
+
import {
|
|
3
|
+
LEGACY_CODER_ENGINE_SLUGS,
|
|
4
|
+
agentsToProvision
|
|
5
|
+
} from "./chunk-5TBV2VHZ.js";
|
|
6
|
+
import {
|
|
7
|
+
POD_DEPLOY_DIR_CANDIDATES,
|
|
8
|
+
SYNAP_BACKEND_CONTAINERS,
|
|
9
|
+
findPodDeployDir,
|
|
10
|
+
resolveSynapUrlOnHost,
|
|
11
|
+
restartBackendContainer
|
|
12
|
+
} from "./chunk-O5WTCVMZ.js";
|
|
13
|
+
import {
|
|
14
|
+
readAgentKey,
|
|
15
|
+
readEveSecrets,
|
|
16
|
+
writeAgentKey,
|
|
17
|
+
writeCodeEngine,
|
|
18
|
+
writeEveSecrets
|
|
19
|
+
} from "./chunk-T7GCAJK5.js";
|
|
20
|
+
|
|
21
|
+
// ../@eve/lifecycle/dist/chunk-6JFSP4GT.js
|
|
22
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
23
|
+
import { join as join2 } from "path";
|
|
24
|
+
import { exec as execCallback } from "child_process";
|
|
25
|
+
import { promisify } from "util";
|
|
26
|
+
import { randomBytes } from "crypto";
|
|
27
|
+
import {
|
|
28
|
+
readFileSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
unlinkSync
|
|
34
|
+
} from "fs";
|
|
35
|
+
import { join, dirname } from "path";
|
|
36
|
+
import { randomUUID } from "crypto";
|
|
37
|
+
var ENV_FILENAME = ".env";
|
|
38
|
+
function envPath(deployDir) {
|
|
39
|
+
return join(deployDir, ENV_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
function readEnvVar(deployDir, key) {
|
|
42
|
+
const path = envPath(deployDir);
|
|
43
|
+
if (!existsSync(path)) return null;
|
|
44
|
+
const text = readFileSync(path, "utf-8");
|
|
45
|
+
let last = null;
|
|
46
|
+
for (const line of text.split(/\r?\n/)) {
|
|
47
|
+
const trimmed = line.trimStart();
|
|
48
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
49
|
+
const eq = line.indexOf("=");
|
|
50
|
+
if (eq < 0) continue;
|
|
51
|
+
if (line.slice(0, eq).trim() === key) {
|
|
52
|
+
last = line.slice(eq + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return last;
|
|
56
|
+
}
|
|
57
|
+
function readEnvFile(deployDir) {
|
|
58
|
+
const path = envPath(deployDir);
|
|
59
|
+
const out = /* @__PURE__ */ new Map();
|
|
60
|
+
if (!existsSync(path)) return out;
|
|
61
|
+
const text = readFileSync(path, "utf-8");
|
|
62
|
+
for (const line of text.split(/\r?\n/)) {
|
|
63
|
+
const trimmed = line.trimStart();
|
|
64
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
65
|
+
const eq = line.indexOf("=");
|
|
66
|
+
if (eq < 0) continue;
|
|
67
|
+
const k = line.slice(0, eq).trim();
|
|
68
|
+
if (!k) continue;
|
|
69
|
+
out.set(k, line.slice(eq + 1));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function writeEnvVar(deployDir, key, value) {
|
|
74
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
75
|
+
throw new Error(`writeEnvVar: invalid env var name ${JSON.stringify(key)}`);
|
|
76
|
+
}
|
|
77
|
+
const path = envPath(deployDir);
|
|
78
|
+
if (!existsSync(dirname(path))) {
|
|
79
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
const existed = existsSync(path);
|
|
82
|
+
const original = existed ? readFileSync(path, "utf-8") : "";
|
|
83
|
+
const hadTrailingNewline = original.endsWith("\n");
|
|
84
|
+
let previous = null;
|
|
85
|
+
let found = false;
|
|
86
|
+
const sourceText = hadTrailingNewline ? original.slice(0, -1) : original;
|
|
87
|
+
const lines = sourceText === "" ? [] : sourceText.split(/\r?\n/);
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
const trimmed = line.trimStart();
|
|
91
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
92
|
+
out.push(line);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const eq = line.indexOf("=");
|
|
96
|
+
if (eq < 0) {
|
|
97
|
+
out.push(line);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const k = line.slice(0, eq).trim();
|
|
101
|
+
if (k !== key) {
|
|
102
|
+
out.push(line);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const val = line.slice(eq + 1);
|
|
106
|
+
if (!found) {
|
|
107
|
+
previous = val;
|
|
108
|
+
found = true;
|
|
109
|
+
if (value !== null) {
|
|
110
|
+
out.push(`${key}=${value}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!found && value !== null) {
|
|
115
|
+
out.push(`${key}=${value}`);
|
|
116
|
+
}
|
|
117
|
+
let next = out.join("\n");
|
|
118
|
+
if (next.length > 0) next += "\n";
|
|
119
|
+
if (existed && next === original) {
|
|
120
|
+
return { changed: false, previous };
|
|
121
|
+
}
|
|
122
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
123
|
+
try {
|
|
124
|
+
writeFileSync(tmp, next, { mode: 384 });
|
|
125
|
+
renameSync(tmp, path);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
try {
|
|
128
|
+
unlinkSync(tmp);
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
throw err;
|
|
132
|
+
}
|
|
133
|
+
return { changed: true, previous };
|
|
134
|
+
}
|
|
135
|
+
var OPENAPI_TIMEOUT_MS = 4e3;
|
|
136
|
+
var AUTH_TIMEOUT_MS = 6e3;
|
|
137
|
+
var ENTITY_POST_TIMEOUT_MS = 6e3;
|
|
138
|
+
var SSE_TIMEOUT_MS = 35e3;
|
|
139
|
+
var ME_TIMEOUT_MS = 6e3;
|
|
140
|
+
var DELETE_TIMEOUT_MS = 4e3;
|
|
141
|
+
var AUTH_NAME = "Synap auth";
|
|
142
|
+
var DISCOVERY_NAME = "Synap public discovery";
|
|
143
|
+
var IDEMPOTENCY_NAME = "Synap idempotency replay";
|
|
144
|
+
var EVENTS_NAME = "Synap event stream";
|
|
145
|
+
var SUB_TOKENS_NAME = "Synap sub-token mode";
|
|
146
|
+
async function runHubProtocolProbes(opts) {
|
|
147
|
+
const synapUrl = opts.synapUrl.replace(/\/+$/, "");
|
|
148
|
+
const apiKey = opts.apiKey;
|
|
149
|
+
const signal = opts.signal;
|
|
150
|
+
const runner = opts.runner ?? new FetchRunner();
|
|
151
|
+
const onRunnerNote = opts.onRunnerNote;
|
|
152
|
+
const auth = await probeSynapAuth(runner, synapUrl, apiKey, signal, onRunnerNote);
|
|
153
|
+
if (auth.status !== "pass") {
|
|
154
|
+
const skipMsg = auth.status === "skip" ? "Skipped \u2014 Synap URL or API key missing" : "Skipped \u2014 auth probe didn't pass";
|
|
155
|
+
return [
|
|
156
|
+
auth,
|
|
157
|
+
skipDiag("hub-protocol-idempotency", IDEMPOTENCY_NAME, skipMsg),
|
|
158
|
+
skipDiag("hub-protocol-events", EVENTS_NAME, skipMsg),
|
|
159
|
+
skipDiag("hub-protocol-sub-tokens", SUB_TOKENS_NAME, skipMsg)
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
const [idem, sse, subTokens] = await Promise.all([
|
|
163
|
+
probeIdempotency(runner, synapUrl, apiKey, signal),
|
|
164
|
+
probeEventStream(runner, synapUrl, apiKey, signal),
|
|
165
|
+
probeSubTokens(runner, synapUrl, apiKey, signal)
|
|
166
|
+
]);
|
|
167
|
+
return [auth, idem, sse, subTokens];
|
|
168
|
+
}
|
|
169
|
+
async function probeSynapAuth(runner, synapUrl, apiKey, outer, onRunnerNote) {
|
|
170
|
+
const start = Date.now();
|
|
171
|
+
if (!synapUrl) {
|
|
172
|
+
return {
|
|
173
|
+
id: "hub-protocol-openapi",
|
|
174
|
+
name: AUTH_NAME,
|
|
175
|
+
status: "skip",
|
|
176
|
+
message: "Synap apiUrl not configured in secrets",
|
|
177
|
+
fix: "Re-run setup or open the AI page \u2192 Save",
|
|
178
|
+
durationMs: Date.now() - start
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (!apiKey) {
|
|
182
|
+
return {
|
|
183
|
+
id: "hub-protocol-openapi",
|
|
184
|
+
name: AUTH_NAME,
|
|
185
|
+
status: "skip",
|
|
186
|
+
message: "Synap API key missing \u2014 auth probe needs Bearer auth",
|
|
187
|
+
fix: "Re-install Synap (`eve add synap`) to provision an API key",
|
|
188
|
+
durationMs: Date.now() - start
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
void onRunnerNote;
|
|
192
|
+
const result = await getAuthStatus({
|
|
193
|
+
synapUrl,
|
|
194
|
+
apiKey,
|
|
195
|
+
runner,
|
|
196
|
+
signal: outer,
|
|
197
|
+
timeoutMs: AUTH_TIMEOUT_MS
|
|
198
|
+
});
|
|
199
|
+
if (result.ok) {
|
|
200
|
+
return {
|
|
201
|
+
id: "hub-protocol-openapi",
|
|
202
|
+
name: AUTH_NAME,
|
|
203
|
+
status: "pass",
|
|
204
|
+
message: authPassMessage(result.status),
|
|
205
|
+
durationMs: Date.now() - start
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return failureToDiagnostic(result.failure, synapUrl, Date.now() - start);
|
|
209
|
+
}
|
|
210
|
+
function authPassMessage(s) {
|
|
211
|
+
const userTag = s.userId.slice(0, 8);
|
|
212
|
+
const emailPart = s.userEmail ?? "no email";
|
|
213
|
+
const scopes = s.scopes.length > 0 ? `[${s.scopes.join(", ")}]` : "[no scopes]";
|
|
214
|
+
return `Auth OK \u2014 user ${userTag} (${emailPart}), scopes ${scopes}, key age ${s.ageDays}d`;
|
|
215
|
+
}
|
|
216
|
+
function failureToDiagnostic(failure, synapUrl, durationMs) {
|
|
217
|
+
switch (failure.reason) {
|
|
218
|
+
case "key_revoked":
|
|
219
|
+
return {
|
|
220
|
+
id: "hub-protocol-openapi",
|
|
221
|
+
name: AUTH_NAME,
|
|
222
|
+
status: "fail",
|
|
223
|
+
message: failure.message || "API key was revoked or never registered",
|
|
224
|
+
fix: "eve auth renew",
|
|
225
|
+
durationMs
|
|
226
|
+
};
|
|
227
|
+
case "expired":
|
|
228
|
+
return {
|
|
229
|
+
id: "hub-protocol-openapi",
|
|
230
|
+
name: AUTH_NAME,
|
|
231
|
+
status: "fail",
|
|
232
|
+
message: failure.message || "API key expired",
|
|
233
|
+
fix: "eve auth renew",
|
|
234
|
+
durationMs
|
|
235
|
+
};
|
|
236
|
+
case "invalid_format":
|
|
237
|
+
return {
|
|
238
|
+
id: "hub-protocol-openapi",
|
|
239
|
+
name: AUTH_NAME,
|
|
240
|
+
status: "fail",
|
|
241
|
+
message: failure.message || "API key format is malformed",
|
|
242
|
+
fix: "Re-run `eve install` or `eve auth renew`",
|
|
243
|
+
durationMs
|
|
244
|
+
};
|
|
245
|
+
case "no_auth":
|
|
246
|
+
return {
|
|
247
|
+
id: "hub-protocol-openapi",
|
|
248
|
+
name: AUTH_NAME,
|
|
249
|
+
status: "fail",
|
|
250
|
+
message: "No bearer key sent \u2014 internal probe bug or missing agents.eve.hubApiKey",
|
|
251
|
+
fix: "Run: eve auth provision (or check ~/.eve/secrets/secrets.json has agents.eve.hubApiKey set)",
|
|
252
|
+
durationMs
|
|
253
|
+
};
|
|
254
|
+
case "missing_scope":
|
|
255
|
+
return {
|
|
256
|
+
id: "hub-protocol-openapi",
|
|
257
|
+
name: AUTH_NAME,
|
|
258
|
+
status: "fail",
|
|
259
|
+
message: failure.message || `Key lacks required scope '${failure.missingScope ?? "?"}'`,
|
|
260
|
+
fix: failure.missingScope ? `eve auth grant ${failure.missingScope}` : "Re-mint the agent key with the required scopes (eve auth renew)",
|
|
261
|
+
durationMs
|
|
262
|
+
};
|
|
263
|
+
case "backend_unhealthy":
|
|
264
|
+
return {
|
|
265
|
+
id: "hub-protocol-openapi",
|
|
266
|
+
name: AUTH_NAME,
|
|
267
|
+
status: "fail",
|
|
268
|
+
message: failure.message || `Backend returned ${failure.httpStatus}`,
|
|
269
|
+
fix: "docker logs synap-backend-backend-1 --tail 50",
|
|
270
|
+
durationMs
|
|
271
|
+
};
|
|
272
|
+
case "transport":
|
|
273
|
+
return {
|
|
274
|
+
id: "hub-protocol-openapi",
|
|
275
|
+
name: AUTH_NAME,
|
|
276
|
+
status: "fail",
|
|
277
|
+
message: `Cannot reach pod at ${synapUrl} (${failure.message})`,
|
|
278
|
+
fix: "Run `eve doctor` for a deeper view (check Synap container is running)",
|
|
279
|
+
durationMs
|
|
280
|
+
};
|
|
281
|
+
case "unknown":
|
|
282
|
+
default:
|
|
283
|
+
return {
|
|
284
|
+
id: "hub-protocol-openapi",
|
|
285
|
+
name: AUTH_NAME,
|
|
286
|
+
status: "fail",
|
|
287
|
+
message: failure.message || `Auth check failed (${failure.httpStatus})`,
|
|
288
|
+
durationMs
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function probeSynapDiscovery(runner, synapUrl, outer) {
|
|
293
|
+
const start = Date.now();
|
|
294
|
+
if (!synapUrl) {
|
|
295
|
+
return {
|
|
296
|
+
id: "hub-protocol-discovery",
|
|
297
|
+
name: DISCOVERY_NAME,
|
|
298
|
+
status: "skip",
|
|
299
|
+
message: "Synap apiUrl not configured",
|
|
300
|
+
durationMs: Date.now() - start
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const url = `${synapUrl.replace(/\/+$/, "")}/api/hub/openapi.json`;
|
|
304
|
+
const res = await runner.httpGet(
|
|
305
|
+
url,
|
|
306
|
+
{ Accept: "application/json" },
|
|
307
|
+
{ signal: outer, timeoutMs: OPENAPI_TIMEOUT_MS }
|
|
308
|
+
);
|
|
309
|
+
if (res.status === 0) {
|
|
310
|
+
return {
|
|
311
|
+
id: "hub-protocol-discovery",
|
|
312
|
+
name: DISCOVERY_NAME,
|
|
313
|
+
status: "skip",
|
|
314
|
+
message: `Could not reach ${url} (${res.error ?? "network error"})`,
|
|
315
|
+
durationMs: Date.now() - start
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (res.status === 200) {
|
|
319
|
+
let version = "?";
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(res.body);
|
|
322
|
+
if (typeof parsed.openapi === "string") version = parsed.openapi;
|
|
323
|
+
} catch {
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
id: "hub-protocol-discovery",
|
|
327
|
+
name: DISCOVERY_NAME,
|
|
328
|
+
status: "pass",
|
|
329
|
+
message: `Public openapi.json reachable (OpenAPI ${version})`,
|
|
330
|
+
durationMs: Date.now() - start
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (res.status === 401 || res.status === 403) {
|
|
334
|
+
return {
|
|
335
|
+
id: "hub-protocol-discovery",
|
|
336
|
+
name: DISCOVERY_NAME,
|
|
337
|
+
status: "skip",
|
|
338
|
+
message: `Backend still requires auth on /api/hub/openapi.json (${res.status}) \u2014 predates the public-discovery release`,
|
|
339
|
+
durationMs: Date.now() - start
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (res.status === 404) {
|
|
343
|
+
return {
|
|
344
|
+
id: "hub-protocol-discovery",
|
|
345
|
+
name: DISCOVERY_NAME,
|
|
346
|
+
status: "skip",
|
|
347
|
+
message: "Older backend \u2014 /api/hub/openapi.json not exposed",
|
|
348
|
+
durationMs: Date.now() - start
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
id: "hub-protocol-discovery",
|
|
353
|
+
name: DISCOVERY_NAME,
|
|
354
|
+
status: "skip",
|
|
355
|
+
message: `Unexpected status ${res.status} from ${url}`,
|
|
356
|
+
durationMs: Date.now() - start
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
async function probeIdempotency(runner, synapUrl, apiKey, outer) {
|
|
360
|
+
const start = Date.now();
|
|
361
|
+
if (!synapUrl || !apiKey) {
|
|
362
|
+
return skipDiag("hub-protocol-idempotency", IDEMPOTENCY_NAME, "Skipped \u2014 Synap URL or API key missing");
|
|
363
|
+
}
|
|
364
|
+
const url = `${synapUrl}/api/hub/entities`;
|
|
365
|
+
const idempotencyKey = randomUUID();
|
|
366
|
+
const body = {
|
|
367
|
+
profileSlug: "note",
|
|
368
|
+
title: "eve-doctor-idempotency-smoke",
|
|
369
|
+
source: "openwebui-pipeline"
|
|
370
|
+
};
|
|
371
|
+
const bodyJson = JSON.stringify(body);
|
|
372
|
+
const headers = {
|
|
373
|
+
Authorization: `Bearer ${apiKey}`,
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
Accept: "application/json",
|
|
376
|
+
"Idempotency-Key": idempotencyKey
|
|
377
|
+
};
|
|
378
|
+
const post = () => runner.httpPost(url, headers, bodyJson, {
|
|
379
|
+
signal: outer,
|
|
380
|
+
timeoutMs: ENTITY_POST_TIMEOUT_MS
|
|
381
|
+
});
|
|
382
|
+
const first = await post();
|
|
383
|
+
if (first.status === 0) {
|
|
384
|
+
return {
|
|
385
|
+
id: "hub-protocol-idempotency",
|
|
386
|
+
name: IDEMPOTENCY_NAME,
|
|
387
|
+
status: "fail",
|
|
388
|
+
message: `Cannot reach ${url} (${first.error ?? "network error"})`,
|
|
389
|
+
fix: "Check the Synap container is running and the URL is reachable",
|
|
390
|
+
durationMs: Date.now() - start
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (first.status === 401 || first.status === 403) {
|
|
394
|
+
return {
|
|
395
|
+
id: "hub-protocol-idempotency",
|
|
396
|
+
name: IDEMPOTENCY_NAME,
|
|
397
|
+
status: "fail",
|
|
398
|
+
message: `API key rejected \u2014 ${first.status} from POST /api/hub/entities`,
|
|
399
|
+
fix: "Re-run setup to mint a fresh agent API key",
|
|
400
|
+
durationMs: Date.now() - start
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (first.status === 404) {
|
|
404
|
+
return {
|
|
405
|
+
id: "hub-protocol-idempotency",
|
|
406
|
+
name: IDEMPOTENCY_NAME,
|
|
407
|
+
status: "fail",
|
|
408
|
+
message: "POST /api/hub/entities not available \u2014 backend version too old",
|
|
409
|
+
fix: "Update Synap (`eve update synap`)",
|
|
410
|
+
durationMs: Date.now() - start
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (first.status < 200 || first.status >= 300) {
|
|
414
|
+
return {
|
|
415
|
+
id: "hub-protocol-idempotency",
|
|
416
|
+
name: IDEMPOTENCY_NAME,
|
|
417
|
+
status: "fail",
|
|
418
|
+
message: `First POST returned ${first.status}: ${truncate(first.body)}`,
|
|
419
|
+
durationMs: Date.now() - start
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const second = await post();
|
|
423
|
+
if (second.status === 0) {
|
|
424
|
+
return {
|
|
425
|
+
id: "hub-protocol-idempotency",
|
|
426
|
+
name: IDEMPOTENCY_NAME,
|
|
427
|
+
status: "fail",
|
|
428
|
+
message: `Second POST failed (${second.error ?? "network error"}) \u2014 couldn't verify replay`,
|
|
429
|
+
durationMs: Date.now() - start
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (second.status < 200 || second.status >= 300) {
|
|
433
|
+
return {
|
|
434
|
+
id: "hub-protocol-idempotency",
|
|
435
|
+
name: IDEMPOTENCY_NAME,
|
|
436
|
+
status: "fail",
|
|
437
|
+
message: `Second POST returned ${second.status}: ${truncate(second.body)}`,
|
|
438
|
+
durationMs: Date.now() - start
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const entityId = extractEntityId(first.body);
|
|
442
|
+
if (entityId) {
|
|
443
|
+
void deleteEntityBestEffort(synapUrl, apiKey, entityId, runner);
|
|
444
|
+
}
|
|
445
|
+
const replayHeader = second.headers["x-idempotent-replay"];
|
|
446
|
+
if (replayHeader !== "true") {
|
|
447
|
+
return {
|
|
448
|
+
id: "hub-protocol-idempotency",
|
|
449
|
+
name: IDEMPOTENCY_NAME,
|
|
450
|
+
status: "fail",
|
|
451
|
+
message: "Backend doesn't echo X-Idempotent-Replay \u2014 idempotency middleware not mounted",
|
|
452
|
+
fix: "Update Synap (`eve update synap`) \u2014 needs 2026-05+ Hub Protocol",
|
|
453
|
+
durationMs: Date.now() - start
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (first.body !== second.body) {
|
|
457
|
+
return {
|
|
458
|
+
id: "hub-protocol-idempotency",
|
|
459
|
+
name: IDEMPOTENCY_NAME,
|
|
460
|
+
status: "fail",
|
|
461
|
+
message: "Backend created a duplicate entity (idempotency middleware not honouring the key)",
|
|
462
|
+
fix: "Update Synap (`eve update synap`) \u2014 needs 2026-05+ Hub Protocol",
|
|
463
|
+
durationMs: Date.now() - start
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
id: "hub-protocol-idempotency",
|
|
468
|
+
name: IDEMPOTENCY_NAME,
|
|
469
|
+
status: "pass",
|
|
470
|
+
message: "Replay confirmed \u2014 same Idempotency-Key returns the cached response",
|
|
471
|
+
durationMs: Date.now() - start
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
async function probeEventStream(runner, synapUrl, apiKey, outer) {
|
|
475
|
+
const start = Date.now();
|
|
476
|
+
if (!synapUrl || !apiKey) {
|
|
477
|
+
return skipDiag("hub-protocol-events", EVENTS_NAME, "Skipped \u2014 Synap URL or API key missing");
|
|
478
|
+
}
|
|
479
|
+
const url = `${synapUrl}/api/hub/events/stream`;
|
|
480
|
+
const headers = { Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream" };
|
|
481
|
+
const stream = await runner.httpStream(url, headers, { signal: outer, timeoutMs: SSE_TIMEOUT_MS });
|
|
482
|
+
if (!stream.ok) {
|
|
483
|
+
if (stream.error === "stream-not-supported") {
|
|
484
|
+
return skipDiag(
|
|
485
|
+
"hub-protocol-events",
|
|
486
|
+
EVENTS_NAME,
|
|
487
|
+
"Skipped \u2014 SSE probing not supported by current runner (e.g. docker-exec wget without unbuffered piping)"
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (stream.status === 401 || stream.status === 403) {
|
|
491
|
+
return {
|
|
492
|
+
id: "hub-protocol-events",
|
|
493
|
+
name: EVENTS_NAME,
|
|
494
|
+
status: "fail",
|
|
495
|
+
message: `API key rejected on /api/hub/events/stream (${stream.status})`,
|
|
496
|
+
fix: "Re-run setup to mint a fresh agent API key",
|
|
497
|
+
durationMs: Date.now() - start
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (stream.status === 404) {
|
|
501
|
+
return {
|
|
502
|
+
id: "hub-protocol-events",
|
|
503
|
+
name: EVENTS_NAME,
|
|
504
|
+
status: "fail",
|
|
505
|
+
message: "GET /api/hub/events/stream not available \u2014 backend version too old",
|
|
506
|
+
fix: "Update Synap (`eve update synap`)",
|
|
507
|
+
durationMs: Date.now() - start
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (stream.status === 0) {
|
|
511
|
+
const aborted = stream.error?.toLowerCase().includes("abort") || stream.error?.toLowerCase().includes("timeout");
|
|
512
|
+
return {
|
|
513
|
+
id: "hub-protocol-events",
|
|
514
|
+
name: EVENTS_NAME,
|
|
515
|
+
status: "fail",
|
|
516
|
+
message: aborted ? `No SSE response within ${SSE_TIMEOUT_MS / 1e3}s \u2014 proxy may be killing the stream` : `Cannot open event stream at ${url} (${stream.error ?? "network error"})`,
|
|
517
|
+
fix: aborted ? "Check reverse-proxy buffering: nginx needs `proxy_buffering off` for SSE" : "Check the Synap container is running",
|
|
518
|
+
durationMs: Date.now() - start
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
return {
|
|
522
|
+
id: "hub-protocol-events",
|
|
523
|
+
name: EVENTS_NAME,
|
|
524
|
+
status: "fail",
|
|
525
|
+
message: `Event stream returned ${stream.status} (${stream.error ?? "no body"})`,
|
|
526
|
+
durationMs: Date.now() - start
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
let received = false;
|
|
530
|
+
let receivedKind = "";
|
|
531
|
+
let buffer = "";
|
|
532
|
+
let readError = null;
|
|
533
|
+
try {
|
|
534
|
+
if (stream.chunks) {
|
|
535
|
+
for await (const chunk of stream.chunks) {
|
|
536
|
+
buffer += chunk;
|
|
537
|
+
const lines = buffer.split("\n");
|
|
538
|
+
buffer = lines.pop() ?? "";
|
|
539
|
+
for (const line of lines) {
|
|
540
|
+
const trimmed = line.trim();
|
|
541
|
+
if (trimmed.startsWith("event:")) {
|
|
542
|
+
const kind = trimmed.slice(6).trim();
|
|
543
|
+
if (kind === "heartbeat" || kind === "event") {
|
|
544
|
+
received = true;
|
|
545
|
+
receivedKind = kind;
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (received) break;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (!(err instanceof Error && err.name === "AbortError")) {
|
|
555
|
+
readError = err instanceof Error ? err : new Error(String(err));
|
|
556
|
+
}
|
|
557
|
+
} finally {
|
|
558
|
+
await stream.close();
|
|
559
|
+
}
|
|
560
|
+
if (readError) {
|
|
561
|
+
return {
|
|
562
|
+
id: "hub-protocol-events",
|
|
563
|
+
name: EVENTS_NAME,
|
|
564
|
+
status: "fail",
|
|
565
|
+
message: `Stream read error: ${readError.message}`,
|
|
566
|
+
durationMs: Date.now() - start
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
if (!received) {
|
|
570
|
+
return {
|
|
571
|
+
id: "hub-protocol-events",
|
|
572
|
+
name: EVENTS_NAME,
|
|
573
|
+
status: "fail",
|
|
574
|
+
message: `No heartbeat received within ${SSE_TIMEOUT_MS / 1e3}s \u2014 stream may be proxied without flush`,
|
|
575
|
+
fix: "Check reverse-proxy: SSE needs `proxy_buffering off` and no response gzip",
|
|
576
|
+
durationMs: Date.now() - start
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
return {
|
|
580
|
+
id: "hub-protocol-events",
|
|
581
|
+
name: EVENTS_NAME,
|
|
582
|
+
status: "pass",
|
|
583
|
+
message: `Stream open \u2014 received \`${receivedKind}\` frame within ${SSE_TIMEOUT_MS / 1e3}s`,
|
|
584
|
+
durationMs: Date.now() - start
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
async function probeSubTokens(runner, synapUrl, apiKey, outer) {
|
|
588
|
+
const start = Date.now();
|
|
589
|
+
if (!synapUrl || !apiKey) {
|
|
590
|
+
return skipDiag("hub-protocol-sub-tokens", SUB_TOKENS_NAME, "Skipped \u2014 Synap URL or API key missing");
|
|
591
|
+
}
|
|
592
|
+
const url = `${synapUrl}/api/hub/users/me`;
|
|
593
|
+
const externalUserId = `eve-doctor-smoke-${Date.now()}`;
|
|
594
|
+
const fetchMe = async (extraHeaders) => {
|
|
595
|
+
const res = await runner.httpGet(
|
|
596
|
+
url,
|
|
597
|
+
{
|
|
598
|
+
Authorization: `Bearer ${apiKey}`,
|
|
599
|
+
Accept: "application/json",
|
|
600
|
+
...extraHeaders
|
|
601
|
+
},
|
|
602
|
+
{ signal: outer, timeoutMs: ME_TIMEOUT_MS }
|
|
603
|
+
);
|
|
604
|
+
if (res.status === 0) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
status: 0,
|
|
608
|
+
message: res.error ?? "network error"
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
if (res.status < 200 || res.status >= 300) {
|
|
612
|
+
return { ok: false, status: res.status, message: truncate(res.body) };
|
|
613
|
+
}
|
|
614
|
+
let id = null;
|
|
615
|
+
try {
|
|
616
|
+
const parsed = JSON.parse(res.body);
|
|
617
|
+
const candidate = parsed.id ?? parsed.user?.id;
|
|
618
|
+
if (typeof candidate === "string") id = candidate;
|
|
619
|
+
} catch {
|
|
620
|
+
return { ok: false, status: res.status, message: "response was not JSON" };
|
|
621
|
+
}
|
|
622
|
+
return { ok: true, userId: id, status: res.status };
|
|
623
|
+
};
|
|
624
|
+
const parent = await fetchMe({});
|
|
625
|
+
if (!parent.ok) {
|
|
626
|
+
if (parent.status === 401 || parent.status === 403) {
|
|
627
|
+
return {
|
|
628
|
+
id: "hub-protocol-sub-tokens",
|
|
629
|
+
name: SUB_TOKENS_NAME,
|
|
630
|
+
status: "fail",
|
|
631
|
+
message: `API key rejected on /api/hub/users/me (${parent.status})`,
|
|
632
|
+
fix: "Re-run setup to mint a fresh agent API key",
|
|
633
|
+
durationMs: Date.now() - start
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
if (parent.status === 404) {
|
|
637
|
+
return {
|
|
638
|
+
id: "hub-protocol-sub-tokens",
|
|
639
|
+
name: SUB_TOKENS_NAME,
|
|
640
|
+
status: "fail",
|
|
641
|
+
message: "GET /api/hub/users/me not available \u2014 backend version too old",
|
|
642
|
+
fix: "Update Synap (`eve update synap`)",
|
|
643
|
+
durationMs: Date.now() - start
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
id: "hub-protocol-sub-tokens",
|
|
648
|
+
name: SUB_TOKENS_NAME,
|
|
649
|
+
status: "fail",
|
|
650
|
+
message: `Couldn't fetch parent user (${parent.status || "network error"}): ${parent.message}`,
|
|
651
|
+
durationMs: Date.now() - start
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (!parent.userId) {
|
|
655
|
+
return {
|
|
656
|
+
id: "hub-protocol-sub-tokens",
|
|
657
|
+
name: SUB_TOKENS_NAME,
|
|
658
|
+
status: "fail",
|
|
659
|
+
message: "Backend returned /users/me without an id field",
|
|
660
|
+
durationMs: Date.now() - start
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
const sub = await fetchMe({ "X-External-User-Id": externalUserId });
|
|
664
|
+
if (!sub.ok) {
|
|
665
|
+
return {
|
|
666
|
+
id: "hub-protocol-sub-tokens",
|
|
667
|
+
name: SUB_TOKENS_NAME,
|
|
668
|
+
status: "fail",
|
|
669
|
+
message: `Couldn't fetch user with X-External-User-Id (${sub.status || "network error"}): ${sub.message}`,
|
|
670
|
+
durationMs: Date.now() - start
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
if (!sub.userId) {
|
|
674
|
+
return {
|
|
675
|
+
id: "hub-protocol-sub-tokens",
|
|
676
|
+
name: SUB_TOKENS_NAME,
|
|
677
|
+
status: "fail",
|
|
678
|
+
message: "Backend returned /users/me without an id field on sub-token call",
|
|
679
|
+
durationMs: Date.now() - start
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (parent.userId === sub.userId) {
|
|
683
|
+
return {
|
|
684
|
+
id: "hub-protocol-sub-tokens",
|
|
685
|
+
name: SUB_TOKENS_NAME,
|
|
686
|
+
status: "skip",
|
|
687
|
+
message: "Multi-user mode: OFF \u2014 X-External-User-Id ignored (HUB_PROTOCOL_SUB_TOKENS=false)",
|
|
688
|
+
durationMs: Date.now() - start
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
id: "hub-protocol-sub-tokens",
|
|
693
|
+
name: SUB_TOKENS_NAME,
|
|
694
|
+
status: "pass",
|
|
695
|
+
message: `Multi-user mode: ON \u2014 sub-token resolved to a separate user id (parent=${shortId(parent.userId)}, sub=${shortId(sub.userId)})`,
|
|
696
|
+
durationMs: Date.now() - start
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
function skipDiag(id, name, message) {
|
|
700
|
+
return { id, name, status: "skip", message, durationMs: 0 };
|
|
701
|
+
}
|
|
702
|
+
function truncate(text, max = 160) {
|
|
703
|
+
const oneline = text.replace(/\s+/g, " ").trim();
|
|
704
|
+
return oneline.length > max ? `${oneline.slice(0, max)}\u2026` : oneline;
|
|
705
|
+
}
|
|
706
|
+
function shortId(id) {
|
|
707
|
+
return id.length > 8 ? `\u2026${id.slice(-8)}` : id;
|
|
708
|
+
}
|
|
709
|
+
function extractEntityId(text) {
|
|
710
|
+
try {
|
|
711
|
+
const parsed = JSON.parse(text);
|
|
712
|
+
const direct = parsed.id;
|
|
713
|
+
if (typeof direct === "string") return direct;
|
|
714
|
+
const nested = parsed.entity?.id;
|
|
715
|
+
if (typeof nested === "string") return nested;
|
|
716
|
+
return null;
|
|
717
|
+
} catch {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function deleteEntityBestEffort(synapUrl, apiKey, entityId, runner) {
|
|
722
|
+
const r = runner ?? new FetchRunner();
|
|
723
|
+
try {
|
|
724
|
+
await r.httpDelete(
|
|
725
|
+
`${synapUrl}/api/hub/entities/${encodeURIComponent(entityId)}`,
|
|
726
|
+
{ Authorization: `Bearer ${apiKey}` },
|
|
727
|
+
{ timeoutMs: DELETE_TIMEOUT_MS }
|
|
728
|
+
);
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function combineSignals(outer, timeoutMs) {
|
|
733
|
+
const ac = new AbortController();
|
|
734
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
735
|
+
let onAbort = null;
|
|
736
|
+
if (outer) {
|
|
737
|
+
if (outer.aborted) {
|
|
738
|
+
ac.abort();
|
|
739
|
+
} else {
|
|
740
|
+
onAbort = () => ac.abort();
|
|
741
|
+
outer.addEventListener("abort", onAbort);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
signal: ac.signal,
|
|
746
|
+
cleanup: () => {
|
|
747
|
+
clearTimeout(timer);
|
|
748
|
+
if (onAbort && outer) outer.removeEventListener("abort", onAbort);
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function headersToRecord(h) {
|
|
753
|
+
const out = {};
|
|
754
|
+
h.forEach((value, key) => {
|
|
755
|
+
out[key.toLowerCase()] = value;
|
|
756
|
+
});
|
|
757
|
+
return out;
|
|
758
|
+
}
|
|
759
|
+
function isFetchTransportError(err) {
|
|
760
|
+
if (!(err instanceof Error)) return false;
|
|
761
|
+
const cause = err.cause;
|
|
762
|
+
const code = cause?.code;
|
|
763
|
+
if (code === "ECONNREFUSED" || code === "EHOSTUNREACH" || code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
const msg = err.message || "";
|
|
767
|
+
return /\bECONNREFUSED\b|\bEHOSTUNREACH\b|\bENOTFOUND\b/.test(msg);
|
|
768
|
+
}
|
|
769
|
+
var FetchRunner = class {
|
|
770
|
+
name = "fetch";
|
|
771
|
+
async httpGet(url, headers, opts = {}) {
|
|
772
|
+
return this.request("GET", url, headers, void 0, opts);
|
|
773
|
+
}
|
|
774
|
+
async httpPost(url, headers, body, opts = {}) {
|
|
775
|
+
return this.request("POST", url, headers, body, opts);
|
|
776
|
+
}
|
|
777
|
+
async httpDelete(url, headers, opts = {}) {
|
|
778
|
+
return this.request("DELETE", url, headers, void 0, opts);
|
|
779
|
+
}
|
|
780
|
+
async request(method, url, headers, body, opts) {
|
|
781
|
+
const ac = combineSignals(opts.signal, opts.timeoutMs ?? 6e3);
|
|
782
|
+
try {
|
|
783
|
+
const res = await fetch(url, {
|
|
784
|
+
method,
|
|
785
|
+
headers,
|
|
786
|
+
body,
|
|
787
|
+
signal: ac.signal
|
|
788
|
+
});
|
|
789
|
+
const text = await res.text();
|
|
790
|
+
return {
|
|
791
|
+
status: res.status,
|
|
792
|
+
body: text,
|
|
793
|
+
headers: headersToRecord(res.headers)
|
|
794
|
+
};
|
|
795
|
+
} catch (err) {
|
|
796
|
+
return {
|
|
797
|
+
status: 0,
|
|
798
|
+
body: "",
|
|
799
|
+
headers: {},
|
|
800
|
+
error: err instanceof Error ? err.message : "network error"
|
|
801
|
+
};
|
|
802
|
+
} finally {
|
|
803
|
+
ac.cleanup();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
async httpStream(url, headers, opts = {}) {
|
|
807
|
+
const ac = combineSignals(opts.signal, opts.timeoutMs ?? 35e3);
|
|
808
|
+
let res;
|
|
809
|
+
try {
|
|
810
|
+
res = await fetch(url, { method: "GET", headers, signal: ac.signal });
|
|
811
|
+
} catch (err) {
|
|
812
|
+
ac.cleanup();
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
status: 0,
|
|
816
|
+
headers: {},
|
|
817
|
+
close: async () => {
|
|
818
|
+
},
|
|
819
|
+
error: err instanceof Error ? err.message : "network error"
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
if (!res.ok || !res.body) {
|
|
823
|
+
try {
|
|
824
|
+
await res.body?.cancel();
|
|
825
|
+
} catch {
|
|
826
|
+
}
|
|
827
|
+
ac.cleanup();
|
|
828
|
+
return {
|
|
829
|
+
ok: false,
|
|
830
|
+
status: res.status,
|
|
831
|
+
headers: headersToRecord(res.headers),
|
|
832
|
+
close: async () => {
|
|
833
|
+
},
|
|
834
|
+
error: res.body ? void 0 : "no body"
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
const reader = res.body.getReader();
|
|
838
|
+
const decoder = new TextDecoder();
|
|
839
|
+
async function* iterate() {
|
|
840
|
+
while (true) {
|
|
841
|
+
const { done, value } = await reader.read();
|
|
842
|
+
if (done) return;
|
|
843
|
+
if (value) yield decoder.decode(value, { stream: true });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const close = async () => {
|
|
847
|
+
try {
|
|
848
|
+
await reader.cancel();
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
ac.cleanup();
|
|
852
|
+
};
|
|
853
|
+
return {
|
|
854
|
+
ok: true,
|
|
855
|
+
status: res.status,
|
|
856
|
+
headers: headersToRecord(res.headers),
|
|
857
|
+
chunks: iterate(),
|
|
858
|
+
close
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
var execAsync = promisify(execCallback);
|
|
863
|
+
var EVE_AGENT_TYPE = "eve";
|
|
864
|
+
var EVE_EXTRA_SCOPES = ["providers.write"];
|
|
865
|
+
var DEFAULT_TIMEOUT_MS = 6e3;
|
|
866
|
+
async function getAuthStatus(opts) {
|
|
867
|
+
const synapUrl = opts.synapUrl.replace(/\/+$/, "");
|
|
868
|
+
if (!synapUrl) {
|
|
869
|
+
return {
|
|
870
|
+
ok: false,
|
|
871
|
+
failure: {
|
|
872
|
+
reason: "no_auth",
|
|
873
|
+
httpStatus: 0,
|
|
874
|
+
message: "synapUrl not configured"
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
if (!opts.apiKey) {
|
|
879
|
+
return {
|
|
880
|
+
ok: false,
|
|
881
|
+
failure: {
|
|
882
|
+
reason: "no_auth",
|
|
883
|
+
httpStatus: 0,
|
|
884
|
+
message: "apiKey not configured"
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
const runner = opts.runner ?? new FetchRunner();
|
|
889
|
+
const url = `${synapUrl}/api/hub/auth/status`;
|
|
890
|
+
const headers = {
|
|
891
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
892
|
+
Accept: "application/json"
|
|
893
|
+
};
|
|
894
|
+
const res = await runner.httpGet(url, headers, {
|
|
895
|
+
signal: opts.signal,
|
|
896
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
897
|
+
});
|
|
898
|
+
if (res.status === 0) {
|
|
899
|
+
return {
|
|
900
|
+
ok: false,
|
|
901
|
+
failure: {
|
|
902
|
+
reason: "transport",
|
|
903
|
+
httpStatus: 0,
|
|
904
|
+
message: res.error ?? "network error"
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (res.status >= 500) {
|
|
909
|
+
return {
|
|
910
|
+
ok: false,
|
|
911
|
+
failure: {
|
|
912
|
+
reason: "backend_unhealthy",
|
|
913
|
+
httpStatus: res.status,
|
|
914
|
+
message: `Backend returned ${res.status}`,
|
|
915
|
+
raw: tryJson(res.body)
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
if (res.status === 401 || res.status === 403) {
|
|
920
|
+
return parse401(res.status, res.body);
|
|
921
|
+
}
|
|
922
|
+
if (res.status === 404) {
|
|
923
|
+
return {
|
|
924
|
+
ok: false,
|
|
925
|
+
failure: {
|
|
926
|
+
reason: "backend_unhealthy",
|
|
927
|
+
httpStatus: 404,
|
|
928
|
+
message: "GET /api/hub/auth/status not available \u2014 backend version too old",
|
|
929
|
+
raw: tryJson(res.body)
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
if (res.status < 200 || res.status >= 300) {
|
|
934
|
+
return {
|
|
935
|
+
ok: false,
|
|
936
|
+
failure: {
|
|
937
|
+
reason: "unknown",
|
|
938
|
+
httpStatus: res.status,
|
|
939
|
+
message: `Backend returned ${res.status}`,
|
|
940
|
+
raw: tryJson(res.body)
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
const parsed = tryJson(res.body);
|
|
945
|
+
if (!parsed || typeof parsed !== "object") {
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
failure: {
|
|
949
|
+
reason: "unknown",
|
|
950
|
+
httpStatus: res.status,
|
|
951
|
+
message: "auth/status returned non-JSON body"
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const body = parsed;
|
|
956
|
+
const keyId = stringOr(body.keyId, "");
|
|
957
|
+
const userId = stringOr(body.userId, "");
|
|
958
|
+
const createdAtStr = stringOr(body.createdAt, "");
|
|
959
|
+
if (!keyId || !userId || !createdAtStr) {
|
|
960
|
+
return {
|
|
961
|
+
ok: false,
|
|
962
|
+
failure: {
|
|
963
|
+
reason: "unknown",
|
|
964
|
+
httpStatus: res.status,
|
|
965
|
+
message: "auth/status payload missing required fields (keyId/userId/createdAt)",
|
|
966
|
+
raw: parsed
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
const ageDays = computeAgeDays(createdAtStr);
|
|
971
|
+
const status = {
|
|
972
|
+
keyId,
|
|
973
|
+
keyIdPrefix: stringOr(body.keyIdPrefix, keyId.slice(0, 8)),
|
|
974
|
+
userId,
|
|
975
|
+
userEmail: stringOrNull(body.userEmail),
|
|
976
|
+
userName: stringOrNull(body.userName),
|
|
977
|
+
name: stringOrNull(body.name),
|
|
978
|
+
scopes: stringArray(body.scopes),
|
|
979
|
+
createdAt: createdAtStr,
|
|
980
|
+
expiresAt: stringOrNull(body.expiresAt),
|
|
981
|
+
lastUsedAt: stringOrNull(body.lastUsedAt),
|
|
982
|
+
ageDays,
|
|
983
|
+
parentKeyId: stringOrNull(body.parentKeyId),
|
|
984
|
+
isActive: true,
|
|
985
|
+
raw: parsed
|
|
986
|
+
};
|
|
987
|
+
return { ok: true, status };
|
|
988
|
+
}
|
|
989
|
+
async function isKeyValid(opts) {
|
|
990
|
+
const r = await getAuthStatus(opts);
|
|
991
|
+
return r.ok;
|
|
992
|
+
}
|
|
993
|
+
var RENEW_TIMEOUT_MS = 1e4;
|
|
994
|
+
async function renewAgentKey(opts = {}) {
|
|
995
|
+
const agentType = opts.agentType ?? "eve";
|
|
996
|
+
const result = await provisionAgent({
|
|
997
|
+
agentType,
|
|
998
|
+
deployDir: opts.deployDir,
|
|
999
|
+
reason: opts.reason ?? "renew",
|
|
1000
|
+
runner: opts.runner,
|
|
1001
|
+
timeoutMs: opts.timeoutMs,
|
|
1002
|
+
provisioningToken: opts.provisioningToken,
|
|
1003
|
+
idempotent: opts.idempotent
|
|
1004
|
+
});
|
|
1005
|
+
if (result.provisioned) {
|
|
1006
|
+
return {
|
|
1007
|
+
renewed: true,
|
|
1008
|
+
apiKey: result.record.hubApiKey,
|
|
1009
|
+
keyIdPrefix: result.keyIdPrefix,
|
|
1010
|
+
agentType,
|
|
1011
|
+
wasAlreadyPresent: result.wasAlreadyPresent
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return { renewed: false, reason: result.reason, agentType };
|
|
1015
|
+
}
|
|
1016
|
+
async function provisionAgent(opts) {
|
|
1017
|
+
const agentType = opts.agentType;
|
|
1018
|
+
if (!agentType || agentType.trim().length === 0) {
|
|
1019
|
+
return { provisioned: false, agentType: "", reason: "agentType is required" };
|
|
1020
|
+
}
|
|
1021
|
+
const cwd = opts.deployDir ?? process.env.EVE_HOME ?? process.cwd();
|
|
1022
|
+
const secrets = await readEveSecrets(cwd);
|
|
1023
|
+
const synapUrl = (opts.synapUrl ?? await resolveSynapUrlOnHost(secrets)).trim();
|
|
1024
|
+
if (!synapUrl) {
|
|
1025
|
+
const hasSecrets = !!secrets;
|
|
1026
|
+
return {
|
|
1027
|
+
provisioned: false,
|
|
1028
|
+
agentType,
|
|
1029
|
+
reason: hasSecrets ? "synap pod URL unresolved \u2014 set domain.primary or synap.apiUrl in ~/.eve/secrets.json" : "~/.eve/secrets.json not found \u2014 run `eve setup` first to initialise Eve on this server"
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
let tokenLookup = opts.provisioningToken ? { token: opts.provisioningToken, source: "explicit", diagnosticReason: "" } : await resolveProvisioningTokenWithDiagnostics();
|
|
1033
|
+
if (!tokenLookup.token && !opts.provisioningToken) {
|
|
1034
|
+
try {
|
|
1035
|
+
const ensured = await ensurePodProvisioningToken();
|
|
1036
|
+
tokenLookup = {
|
|
1037
|
+
token: ensured.token,
|
|
1038
|
+
source: ensured.source === "generated" ? "file" : "file",
|
|
1039
|
+
diagnosticReason: ""
|
|
1040
|
+
};
|
|
1041
|
+
if (ensured.source === "generated" && ensured.backendRestarted) {
|
|
1042
|
+
await waitForBackend(synapUrl, opts.runner ?? new FetchRunner());
|
|
1043
|
+
}
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
return {
|
|
1046
|
+
provisioned: false,
|
|
1047
|
+
agentType,
|
|
1048
|
+
reason: tokenLookup.diagnosticReason || `Could not auto-generate PROVISIONING_TOKEN: ${err instanceof Error ? err.message : String(err)}`
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (!tokenLookup.token) {
|
|
1053
|
+
return {
|
|
1054
|
+
provisioned: false,
|
|
1055
|
+
agentType,
|
|
1056
|
+
reason: tokenLookup.diagnosticReason
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
const provisioningToken = tokenLookup.token;
|
|
1060
|
+
const runner = opts.runner ?? new FetchRunner();
|
|
1061
|
+
const url = `${synapUrl.replace(/\/+$/, "")}/api/hub/setup/agent`;
|
|
1062
|
+
const previousPrefix = (secrets?.agents?.[agentType]?.hubApiKey ?? secrets?.synap?.apiKey ?? "").slice(0, 8);
|
|
1063
|
+
const body = JSON.stringify({
|
|
1064
|
+
agentType,
|
|
1065
|
+
// Scopes BEYOND the pod's shared agent bundle, granted per-agent.
|
|
1066
|
+
//
|
|
1067
|
+
// Only `eve` asks, and only for `providers.write`. Writing an ai_providers
|
|
1068
|
+
// row sets the URL the pod sends every prompt to, which is why the pod keeps
|
|
1069
|
+
// that scope out of the default bundle — so requesting it for every agent
|
|
1070
|
+
// here would undo the narrowing rather than satisfy it. `eve` needs it
|
|
1071
|
+
// because it is the operator tool that registers providers (e.g. the
|
|
1072
|
+
// FreeLLMAPI gateway) on the user's behalf.
|
|
1073
|
+
//
|
|
1074
|
+
// The pod refuses this from a weak surface key; we authenticate with the
|
|
1075
|
+
// PROVISIONING_TOKEN, which is operator-grade. An older pod without the
|
|
1076
|
+
// scope ignores the unknown field — the key simply lacks it, and
|
|
1077
|
+
// `upsertPodProvider` then reports the 403 in plain words.
|
|
1078
|
+
...agentType === EVE_AGENT_TYPE ? { extraScopes: EVE_EXTRA_SCOPES } : {},
|
|
1079
|
+
// Diagnostic hints — backend ignores unknown fields. Useful in pod
|
|
1080
|
+
// logs to attribute key churn ("renew", "migrate", "install") and
|
|
1081
|
+
// to chain new keys back to the previous one if the backend tracks
|
|
1082
|
+
// parentKeyId.
|
|
1083
|
+
name: `eve-${agentType}`,
|
|
1084
|
+
parentKeyIdPrefix: previousPrefix || void 0,
|
|
1085
|
+
reason: opts.reason ?? "provision",
|
|
1086
|
+
idempotent: opts.idempotent ?? false
|
|
1087
|
+
});
|
|
1088
|
+
const headers = {
|
|
1089
|
+
Authorization: `Bearer ${provisioningToken}`,
|
|
1090
|
+
"Content-Type": "application/json",
|
|
1091
|
+
Accept: "application/json"
|
|
1092
|
+
};
|
|
1093
|
+
const res = await runner.httpPost(url, headers, body, {
|
|
1094
|
+
timeoutMs: opts.timeoutMs ?? RENEW_TIMEOUT_MS
|
|
1095
|
+
});
|
|
1096
|
+
if (res.status === 0) {
|
|
1097
|
+
return {
|
|
1098
|
+
provisioned: false,
|
|
1099
|
+
agentType,
|
|
1100
|
+
reason: `Cannot reach ${url} (${res.error ?? "network error"})`
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (res.status === 202) {
|
|
1104
|
+
const parsed2 = tryJson(res.body);
|
|
1105
|
+
const adminUrl = typeof parsed2 === "object" && parsed2 && typeof parsed2.adminUrl === "string" ? parsed2.adminUrl : null;
|
|
1106
|
+
return {
|
|
1107
|
+
provisioned: false,
|
|
1108
|
+
agentType,
|
|
1109
|
+
reason: `Backend deferred the request (issuer pending approval). Visit ${adminUrl ?? `${synapUrl}/admin/trusted-issuers`} to approve, then retry.`
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (res.status === 401 || res.status === 403) {
|
|
1113
|
+
return {
|
|
1114
|
+
provisioned: false,
|
|
1115
|
+
agentType,
|
|
1116
|
+
reason: `PROVISIONING_TOKEN rejected (${res.status}) \u2014 token expired, wrong, or backend doesn't accept it. Re-check deploy/.env or run \`eve install\`.`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (res.status === 404) {
|
|
1120
|
+
const parsed404 = tryJson(res.body);
|
|
1121
|
+
const isBackend404 = parsed404 !== null && typeof parsed404 === "object" && ("error" in parsed404 || "message" in parsed404);
|
|
1122
|
+
if (isBackend404) {
|
|
1123
|
+
const setupUrl = synapUrl.replace(/127\.0\.0\.1:\d+/, "pod.<your-domain>").replace(/:\d+$/, "");
|
|
1124
|
+
return {
|
|
1125
|
+
provisioned: false,
|
|
1126
|
+
agentType,
|
|
1127
|
+
reason: `No admin account on this pod \u2014 the backend returned 404 because there is no workspace to create the agent in. Create your first admin at ${setupUrl}/setup (or run \`eve auth provision --email <you@example.com>\` to be prompted inline), then retry.`
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
return {
|
|
1131
|
+
provisioned: false,
|
|
1132
|
+
agentType,
|
|
1133
|
+
reason: `POST ${url} returned 404 (route not matched). Check Traefik routes with \`eve domain check\` \u2014 or the backend image may be outdated (run \`eve update synap\`). Raw: ${res.body.slice(0, 120)}`
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1137
|
+
return {
|
|
1138
|
+
provisioned: false,
|
|
1139
|
+
agentType,
|
|
1140
|
+
reason: `Backend returned ${res.status}: ${res.body.slice(0, 160)}`
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
const parsed = tryJson(res.body);
|
|
1144
|
+
if (!parsed || typeof parsed !== "object") {
|
|
1145
|
+
return {
|
|
1146
|
+
provisioned: false,
|
|
1147
|
+
agentType,
|
|
1148
|
+
reason: "Mint response was not JSON"
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
const obj = parsed;
|
|
1152
|
+
if (obj.alreadyValid === true) {
|
|
1153
|
+
const serverWorkspaceId = stringOr(obj.workspaceId, "");
|
|
1154
|
+
try {
|
|
1155
|
+
const existing = await readAgentKey(agentType, cwd);
|
|
1156
|
+
if (existing?.hubApiKey) {
|
|
1157
|
+
const updated = {
|
|
1158
|
+
...existing,
|
|
1159
|
+
...serverWorkspaceId ? { workspaceId: serverWorkspaceId } : {}
|
|
1160
|
+
};
|
|
1161
|
+
await writeAgentKey(agentType, updated, cwd);
|
|
1162
|
+
return {
|
|
1163
|
+
provisioned: true,
|
|
1164
|
+
agentType,
|
|
1165
|
+
record: updated,
|
|
1166
|
+
keyIdPrefix: (updated.keyId ?? updated.hubApiKey).slice(0, 8),
|
|
1167
|
+
wasAlreadyPresent: true
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
} catch {
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
const hubApiKey = stringOr(obj.hubApiKey, stringOr(obj.apiKey, ""));
|
|
1174
|
+
if (!hubApiKey) {
|
|
1175
|
+
return {
|
|
1176
|
+
provisioned: false,
|
|
1177
|
+
agentType,
|
|
1178
|
+
reason: "Mint response did not include hubApiKey"
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
const agentUserId = stringOr(obj.agentUserId, "");
|
|
1182
|
+
let workspaceId = stringOr(obj.workspaceId, "");
|
|
1183
|
+
if (!workspaceId) {
|
|
1184
|
+
try {
|
|
1185
|
+
const existing = await readAgentKey(agentType, cwd);
|
|
1186
|
+
if (existing?.workspaceId) workspaceId = existing.workspaceId;
|
|
1187
|
+
} catch {
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const keyId = stringOr(obj.keyId, "");
|
|
1191
|
+
const keyIdPrefix = stringOr(
|
|
1192
|
+
obj.keyIdPrefix,
|
|
1193
|
+
keyId.slice(0, 8) || hubApiKey.slice(0, 8)
|
|
1194
|
+
);
|
|
1195
|
+
if (!agentUserId) {
|
|
1196
|
+
return {
|
|
1197
|
+
provisioned: false,
|
|
1198
|
+
agentType,
|
|
1199
|
+
reason: "Mint response missing required field: agentUserId"
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const record = {
|
|
1203
|
+
hubApiKey,
|
|
1204
|
+
agentUserId,
|
|
1205
|
+
workspaceId,
|
|
1206
|
+
keyId: keyId || void 0,
|
|
1207
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1208
|
+
};
|
|
1209
|
+
try {
|
|
1210
|
+
await writeAgentKey(agentType, record, cwd);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
return {
|
|
1213
|
+
provisioned: false,
|
|
1214
|
+
agentType,
|
|
1215
|
+
reason: `Mint succeeded but secrets.json write failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
try {
|
|
1219
|
+
await writeEveSecrets({ pod: { bootstrapToken: provisioningToken } }, cwd);
|
|
1220
|
+
} catch {
|
|
1221
|
+
}
|
|
1222
|
+
return { provisioned: true, agentType, record, keyIdPrefix };
|
|
1223
|
+
}
|
|
1224
|
+
async function provisionAllAgents(opts) {
|
|
1225
|
+
const cwd = opts.deployDir ?? process.env.EVE_HOME ?? process.cwd();
|
|
1226
|
+
const skipIfPresent = opts.skipIfPresent ?? true;
|
|
1227
|
+
const agents = agentsToProvision(opts.installedComponentIds);
|
|
1228
|
+
const results = [];
|
|
1229
|
+
for (const agent of agents) {
|
|
1230
|
+
if (skipIfPresent) {
|
|
1231
|
+
const existing = await readAgentKey(agent.agentType, cwd);
|
|
1232
|
+
if (existing && existing.hubApiKey) {
|
|
1233
|
+
results.push({
|
|
1234
|
+
provisioned: true,
|
|
1235
|
+
agentType: agent.agentType,
|
|
1236
|
+
record: existing,
|
|
1237
|
+
keyIdPrefix: (existing.keyId ?? existing.hubApiKey).slice(0, 8),
|
|
1238
|
+
wasAlreadyPresent: true
|
|
1239
|
+
});
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
const result = await provisionAgent({
|
|
1244
|
+
agentType: agent.agentType,
|
|
1245
|
+
deployDir: cwd,
|
|
1246
|
+
reason: opts.reason ?? "provision-all",
|
|
1247
|
+
runner: opts.runner,
|
|
1248
|
+
synapUrl: opts.synapUrl,
|
|
1249
|
+
provisioningToken: opts.provisioningToken
|
|
1250
|
+
});
|
|
1251
|
+
results.push(result);
|
|
1252
|
+
}
|
|
1253
|
+
return results;
|
|
1254
|
+
}
|
|
1255
|
+
async function migrateLegacyToAgents(opts) {
|
|
1256
|
+
const cwd = opts.deployDir ?? process.env.EVE_HOME ?? process.cwd();
|
|
1257
|
+
const secretsBefore = await readEveSecrets(cwd);
|
|
1258
|
+
const collapse = await collapseLegacyCoderAgents(secretsBefore, cwd);
|
|
1259
|
+
const eveExisting = await readAgentKey("eve", cwd);
|
|
1260
|
+
const needsPerAgent = !eveExisting || !eveExisting.hubApiKey;
|
|
1261
|
+
if (!needsPerAgent && !collapse.collapsed) {
|
|
1262
|
+
return {
|
|
1263
|
+
migrated: false,
|
|
1264
|
+
results: [],
|
|
1265
|
+
reason: "already migrated \u2014 eve agent key present and no legacy coder slugs"
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
const results = await provisionAllAgents({
|
|
1269
|
+
installedComponentIds: opts.installedComponentIds,
|
|
1270
|
+
deployDir: cwd,
|
|
1271
|
+
reason: collapse.collapsed ? "migrate-legacy-coder-collapse" : "migrate-legacy",
|
|
1272
|
+
runner: opts.runner,
|
|
1273
|
+
skipIfPresent: !needsPerAgent
|
|
1274
|
+
});
|
|
1275
|
+
const anyOk = results.some((r) => r.provisioned);
|
|
1276
|
+
return {
|
|
1277
|
+
migrated: anyOk || collapse.collapsed,
|
|
1278
|
+
results,
|
|
1279
|
+
reason: anyOk ? void 0 : collapse.collapsed ? "coder collapse applied; new agent provisioning failed \u2014 see per-agent reasons" : "no agent could be provisioned \u2014 see per-agent reasons",
|
|
1280
|
+
collapsedToEngine: collapse.engine,
|
|
1281
|
+
strippedLegacySlugs: collapse.stripped
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
async function collapseLegacyCoderAgents(secrets, cwd) {
|
|
1285
|
+
if (!secrets) return { collapsed: false, stripped: [] };
|
|
1286
|
+
const agents = secrets.agents ?? {};
|
|
1287
|
+
const presentLegacy = LEGACY_CODER_ENGINE_SLUGS.filter(
|
|
1288
|
+
(slug) => agents[slug] !== void 0
|
|
1289
|
+
);
|
|
1290
|
+
if (presentLegacy.length === 0) {
|
|
1291
|
+
return { collapsed: false, stripped: [] };
|
|
1292
|
+
}
|
|
1293
|
+
const existingChoice = secrets.builder?.codeEngine;
|
|
1294
|
+
const pickedEngine = existingChoice && LEGACY_CODER_ENGINE_SLUGS.includes(existingChoice) ? existingChoice : presentLegacy[0];
|
|
1295
|
+
await writeCodeEngine(pickedEngine, cwd);
|
|
1296
|
+
const nextAgents = {};
|
|
1297
|
+
for (const [slug, rec] of Object.entries(agents)) {
|
|
1298
|
+
if (LEGACY_CODER_ENGINE_SLUGS.includes(slug)) continue;
|
|
1299
|
+
nextAgents[slug] = rec;
|
|
1300
|
+
}
|
|
1301
|
+
await writeEveSecrets({ agents: nextAgents }, cwd);
|
|
1302
|
+
return { collapsed: true, engine: pickedEngine, stripped: presentLegacy };
|
|
1303
|
+
}
|
|
1304
|
+
async function resolveProvisioningToken() {
|
|
1305
|
+
return (await resolveProvisioningTokenWithDiagnostics()).token;
|
|
1306
|
+
}
|
|
1307
|
+
async function waitForBackend(synapUrl, runner, opts = {}) {
|
|
1308
|
+
const timeoutMs = opts.timeoutMs ?? 2e4;
|
|
1309
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
1310
|
+
const url = `${synapUrl.replace(/\/+$/, "")}/api/hub/health`;
|
|
1311
|
+
const start = Date.now();
|
|
1312
|
+
while (Date.now() - start < timeoutMs) {
|
|
1313
|
+
try {
|
|
1314
|
+
const res = await runner.httpGet(url, {}, { timeoutMs: 1500 });
|
|
1315
|
+
if (res.status >= 200 && res.status < 500) return;
|
|
1316
|
+
} catch {
|
|
1317
|
+
}
|
|
1318
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
async function ensurePodProvisioningToken() {
|
|
1322
|
+
const probe = await resolveProvisioningTokenWithDiagnostics();
|
|
1323
|
+
if (probe.token) {
|
|
1324
|
+
return { token: probe.token, source: "existing" };
|
|
1325
|
+
}
|
|
1326
|
+
const deployDir = findPodDeployDir();
|
|
1327
|
+
if (!deployDir) {
|
|
1328
|
+
throw new Error(
|
|
1329
|
+
"No pod deploy directory found \u2014 set SYNAP_DEPLOY_DIR or install synap-backend first (checked: " + POD_DEPLOY_DIR_CANDIDATES.join(", ") + ")."
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
const token = randomBytes(32).toString("hex");
|
|
1333
|
+
const result = writeEnvVar(deployDir, "PROVISIONING_TOKEN", token);
|
|
1334
|
+
const envFilePath = join2(deployDir, ".env");
|
|
1335
|
+
const backendRestarted = restartBackendContainer(deployDir);
|
|
1336
|
+
void result;
|
|
1337
|
+
return {
|
|
1338
|
+
token,
|
|
1339
|
+
source: "generated",
|
|
1340
|
+
writtenTo: envFilePath,
|
|
1341
|
+
backendRestarted
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
async function resolveProvisioningTokenWithDiagnostics() {
|
|
1345
|
+
const envName = process.env.EVE_PROVISIONING_TOKEN !== void 0 ? "EVE_PROVISIONING_TOKEN" : process.env.PROVISIONING_TOKEN !== void 0 ? "PROVISIONING_TOKEN" : null;
|
|
1346
|
+
const envValue = process.env.EVE_PROVISIONING_TOKEN ?? process.env.PROVISIONING_TOKEN;
|
|
1347
|
+
if (envName !== null && (envValue ?? "").trim().length === 0) {
|
|
1348
|
+
return {
|
|
1349
|
+
token: null,
|
|
1350
|
+
source: "empty",
|
|
1351
|
+
diagnosticReason: `${envName} is set but empty. Either unset it (so we can fall back to the pod's deploy/.env) or assign a real value (\`openssl rand -hex 32\`).`
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
if (envValue && envValue.trim().length > 0) {
|
|
1355
|
+
return { token: envValue.trim(), source: "env", diagnosticReason: "" };
|
|
1356
|
+
}
|
|
1357
|
+
const dirCandidates = [
|
|
1358
|
+
process.env.SYNAP_DEPLOY_DIR,
|
|
1359
|
+
"/opt/synap-backend/deploy",
|
|
1360
|
+
"/opt/synap-backend",
|
|
1361
|
+
"/opt/synap/deploy",
|
|
1362
|
+
"/opt/synap",
|
|
1363
|
+
"/opt/synap-pod/deploy",
|
|
1364
|
+
"/opt/synap-pod",
|
|
1365
|
+
"/srv/synap-backend/deploy",
|
|
1366
|
+
"/srv/synap/deploy"
|
|
1367
|
+
].filter((d) => typeof d === "string" && d.length > 0);
|
|
1368
|
+
let fileFoundEmpty = false;
|
|
1369
|
+
let fileFoundEmptyAt = "";
|
|
1370
|
+
for (const dir of dirCandidates) {
|
|
1371
|
+
const envPath2 = join2(dir, ".env");
|
|
1372
|
+
if (!existsSync2(envPath2)) continue;
|
|
1373
|
+
const probe = readEnvFileVarWithStatus(envPath2, "PROVISIONING_TOKEN");
|
|
1374
|
+
if (probe.value) {
|
|
1375
|
+
return { token: probe.value, source: "file", diagnosticReason: "" };
|
|
1376
|
+
}
|
|
1377
|
+
if (probe.status === "found-empty" && !fileFoundEmpty) {
|
|
1378
|
+
fileFoundEmpty = true;
|
|
1379
|
+
fileFoundEmptyAt = envPath2;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
const dockerProbe = await readProvisioningTokenFromDockerWithStatus();
|
|
1383
|
+
if (dockerProbe.value) {
|
|
1384
|
+
return { token: dockerProbe.value, source: "docker", diagnosticReason: "" };
|
|
1385
|
+
}
|
|
1386
|
+
if (fileFoundEmpty || dockerProbe.status === "found-empty") {
|
|
1387
|
+
const where = fileFoundEmpty ? fileFoundEmptyAt : "the running synap-backend container's env";
|
|
1388
|
+
return {
|
|
1389
|
+
token: null,
|
|
1390
|
+
source: "empty",
|
|
1391
|
+
diagnosticReason: `PROVISIONING_TOKEN is empty in ${where}. The pod was deployed without filling it in. Fix: \`openssl rand -hex 32\` and write the value into the pod's deploy/.env, then \`docker compose up -d backend\` (or \`docker restart synap-backend-backend-1\`) and retry. See \`eve auth bootstrap-token\` for an automated path once supported.`
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
return {
|
|
1395
|
+
token: null,
|
|
1396
|
+
source: "missing",
|
|
1397
|
+
diagnosticReason: "PROVISIONING_TOKEN unavailable. Set EVE_PROVISIONING_TOKEN, or ensure the pod's deploy/.env is readable. PROVISIONING_TOKEN was generated when synap-backend was first installed and lives in deploy/.env on the pod host."
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
function readEnvFileVarWithStatus(envPath2, key) {
|
|
1401
|
+
try {
|
|
1402
|
+
const text = readFileSync2(envPath2, "utf-8");
|
|
1403
|
+
let sawEmpty = false;
|
|
1404
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1405
|
+
const line = raw.trim();
|
|
1406
|
+
if (!line || line.startsWith("#")) continue;
|
|
1407
|
+
const eq = line.indexOf("=");
|
|
1408
|
+
if (eq < 0) continue;
|
|
1409
|
+
if (line.slice(0, eq).trim() === key) {
|
|
1410
|
+
const value = line.slice(eq + 1).trim();
|
|
1411
|
+
if (value.length > 0) return { value, status: "found" };
|
|
1412
|
+
sawEmpty = true;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return { value: null, status: sawEmpty ? "found-empty" : "absent" };
|
|
1416
|
+
} catch {
|
|
1417
|
+
return { value: null, status: "absent" };
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
async function readProvisioningTokenFromDockerWithStatus() {
|
|
1421
|
+
let sawEmpty = false;
|
|
1422
|
+
for (const container of SYNAP_BACKEND_CONTAINERS) {
|
|
1423
|
+
try {
|
|
1424
|
+
const { stdout: out } = await execAsync(
|
|
1425
|
+
`docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' ${container}`,
|
|
1426
|
+
{ encoding: "utf-8", timeout: 4e3 }
|
|
1427
|
+
);
|
|
1428
|
+
for (const raw of out.split(/\r?\n/)) {
|
|
1429
|
+
const line = raw.trim();
|
|
1430
|
+
if (!line) continue;
|
|
1431
|
+
const eq = line.indexOf("=");
|
|
1432
|
+
if (eq < 0) continue;
|
|
1433
|
+
if (line.slice(0, eq) === "PROVISIONING_TOKEN") {
|
|
1434
|
+
const value = line.slice(eq + 1).trim();
|
|
1435
|
+
if (value.length > 0) return { value, status: "found" };
|
|
1436
|
+
sawEmpty = true;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
} catch {
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
return { value: null, status: sawEmpty ? "found-empty" : "absent" };
|
|
1443
|
+
}
|
|
1444
|
+
var STRUCTURED_REASONS = /* @__PURE__ */ new Set([
|
|
1445
|
+
"key_revoked",
|
|
1446
|
+
"missing_scope",
|
|
1447
|
+
"expired",
|
|
1448
|
+
"invalid_format",
|
|
1449
|
+
"no_auth"
|
|
1450
|
+
]);
|
|
1451
|
+
function parse401(httpStatus, body) {
|
|
1452
|
+
const parsed = tryJson(body);
|
|
1453
|
+
if (parsed && typeof parsed === "object") {
|
|
1454
|
+
const obj = parsed;
|
|
1455
|
+
const reasonStr = stringOr(obj.reason, "");
|
|
1456
|
+
if (reasonStr && STRUCTURED_REASONS.has(reasonStr)) {
|
|
1457
|
+
return {
|
|
1458
|
+
ok: false,
|
|
1459
|
+
failure: {
|
|
1460
|
+
reason: reasonStr,
|
|
1461
|
+
httpStatus,
|
|
1462
|
+
message: stringOr(obj.message, `unauthorized (${reasonStr})`),
|
|
1463
|
+
missingScope: stringOr(obj.missingScope, "") || void 0,
|
|
1464
|
+
keyIdPrefix: stringOr(obj.keyIdPrefix, "") || void 0,
|
|
1465
|
+
raw: parsed
|
|
1466
|
+
}
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return {
|
|
1471
|
+
ok: false,
|
|
1472
|
+
failure: {
|
|
1473
|
+
reason: "key_revoked",
|
|
1474
|
+
httpStatus,
|
|
1475
|
+
message: `unauthorized (${httpStatus}) \u2014 backend did not return a structured envelope`,
|
|
1476
|
+
raw: parsed ?? body
|
|
1477
|
+
}
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function stringOr(v, fallback) {
|
|
1481
|
+
return typeof v === "string" ? v : fallback;
|
|
1482
|
+
}
|
|
1483
|
+
function stringOrNull(v) {
|
|
1484
|
+
return typeof v === "string" ? v : null;
|
|
1485
|
+
}
|
|
1486
|
+
function stringArray(v) {
|
|
1487
|
+
if (!Array.isArray(v)) return [];
|
|
1488
|
+
return v.filter((x) => typeof x === "string");
|
|
1489
|
+
}
|
|
1490
|
+
function tryJson(text) {
|
|
1491
|
+
if (!text) return null;
|
|
1492
|
+
try {
|
|
1493
|
+
return JSON.parse(text);
|
|
1494
|
+
} catch {
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
function computeAgeDays(createdAt) {
|
|
1499
|
+
const t = Date.parse(createdAt);
|
|
1500
|
+
if (Number.isNaN(t)) return 0;
|
|
1501
|
+
const ms = Date.now() - t;
|
|
1502
|
+
if (ms <= 0) return 0;
|
|
1503
|
+
return Math.floor(ms / (1e3 * 60 * 60 * 24));
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
export {
|
|
1507
|
+
readEnvVar,
|
|
1508
|
+
readEnvFile,
|
|
1509
|
+
writeEnvVar,
|
|
1510
|
+
runHubProtocolProbes,
|
|
1511
|
+
probeSynapDiscovery,
|
|
1512
|
+
truncate,
|
|
1513
|
+
shortId,
|
|
1514
|
+
extractEntityId,
|
|
1515
|
+
deleteEntityBestEffort,
|
|
1516
|
+
combineSignals,
|
|
1517
|
+
isFetchTransportError,
|
|
1518
|
+
FetchRunner,
|
|
1519
|
+
getAuthStatus,
|
|
1520
|
+
isKeyValid,
|
|
1521
|
+
renewAgentKey,
|
|
1522
|
+
provisionAgent,
|
|
1523
|
+
provisionAllAgents,
|
|
1524
|
+
migrateLegacyToAgents,
|
|
1525
|
+
resolveProvisioningToken,
|
|
1526
|
+
waitForBackend,
|
|
1527
|
+
ensurePodProvisioningToken
|
|
1528
|
+
};
|
|
1529
|
+
//# sourceMappingURL=chunk-JO5H5G6U.js.map
|