ima2-gen 3.9.0 → 3.10.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.
Files changed (42) hide show
  1. package/README.md +14 -3
  2. package/bin/commands/service.js +450 -0
  3. package/bin/commands/stop.js +129 -0
  4. package/bin/ima2.js +15 -1
  5. package/bin/lib/serviceTemplates.js +87 -0
  6. package/docs/API.md +1 -0
  7. package/docs/migration/runtime-test-inventory.md +3 -1
  8. package/lib/processControl.js +138 -0
  9. package/lib/runtimeContext.js +1 -0
  10. package/package.json +2 -2
  11. package/routes/admin.js +51 -0
  12. package/routes/edit.js +21 -0
  13. package/routes/index.js +2 -0
  14. package/server.js +12 -3
  15. package/ui/dist/.vite/manifest.json +32 -32
  16. package/ui/dist/assets/{AgentWorkspace-fcIXiWV6.js → AgentWorkspace-DAOw-Q1x.js} +1 -1
  17. package/ui/dist/assets/AssetGenWorkspace-Bhi2OrfW.js +2 -0
  18. package/ui/dist/assets/AssetsWorkspace-D-pcyPvz.js +1 -0
  19. package/ui/dist/assets/{CardNewsWorkspace-ltdjG40R.js → CardNewsWorkspace-BETskmU7.js} +1 -1
  20. package/ui/dist/assets/{GenerationRequestLogPanel-B68EUfUg.js → GenerationRequestLogPanel-sr-wSiOe.js} +1 -1
  21. package/ui/dist/assets/{HomeWorkspace-BBOG_aNm.js → HomeWorkspace-vjN-A1AR.js} +1 -1
  22. package/ui/dist/assets/{KeyingPanel-Bv0n_bhk.js → KeyingPanel-D7iozqcS.js} +1 -1
  23. package/ui/dist/assets/{NodeCanvas-VwdctvAW.js → NodeCanvas-Bu6q2z3V.js} +1 -1
  24. package/ui/dist/assets/{PromptBuilderPanel-BBMpF_3Q.js → PromptBuilderPanel-DklSQCS_.js} +2 -2
  25. package/ui/dist/assets/{PromptImportDialog-D4QYbRfH.js → PromptImportDialog-CZwFZDvz.js} +2 -2
  26. package/ui/dist/assets/{PromptImportDiscoverySection-Bx7BeTFh.js → PromptImportDiscoverySection-B5Lyhl9X.js} +1 -1
  27. package/ui/dist/assets/{PromptImportFolderSection-Bwf60URx.js → PromptImportFolderSection-DmMFtYmn.js} +1 -1
  28. package/ui/dist/assets/{PromptLibraryPanel-mNG2wZkQ.js → PromptLibraryPanel-BHr73ns_.js} +2 -2
  29. package/ui/dist/assets/SettingsWorkspace-DrQ44R-B.js +1 -0
  30. package/ui/dist/assets/{SpriteRecipeWorkspace-Da9qTQX0.js → SpriteRecipeWorkspace-ChboDVCj.js} +1 -1
  31. package/ui/dist/assets/index-C4IY6hxw.css +1 -0
  32. package/ui/dist/assets/index-CVVv0v-b.js +30 -0
  33. package/ui/dist/assets/index-D1SD3LcV.js +5 -0
  34. package/ui/dist/assets/{pptxgen.es-BE4CSd5F.js → pptxgen.es-DIQkG3ve.js} +1 -1
  35. package/ui/dist/assets/{useAgentDialogFocus-R64NV-ZZ.js → useAgentDialogFocus-CqjQmoEH.js} +1 -1
  36. package/ui/dist/index.html +15 -3
  37. package/ui/dist/assets/AssetGenWorkspace-CqXyN9at.js +0 -2
  38. package/ui/dist/assets/AssetsWorkspace-xwHFVOb7.js +0 -1
  39. package/ui/dist/assets/SettingsWorkspace-DA5tIa0Q.js +0 -1
  40. package/ui/dist/assets/index-Bv1Yrz8W.js +0 -5
  41. package/ui/dist/assets/index-C0nfanMr.css +0 -1
  42. package/ui/dist/assets/index-CxEuSZMC.js +0 -30
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Pure renderers for background-service artifacts. Kept free of process/fs so
3
+ * contract tests can snapshot them (adversarial audit 260821c: PATH must be
4
+ * baked — launchd/systemd hand jobs a minimal environment and the grok/oauth
5
+ * proxies spawn bare binaries that silently die without the user's PATH).
6
+ */
7
+ export const LAUNCHD_LABEL = "com.ima2.server";
8
+ export const SYSTEMD_UNIT = "ima2.service";
9
+ function xmlEscape(value) {
10
+ return value
11
+ .replaceAll("&", "&")
12
+ .replaceAll("<", "&lt;")
13
+ .replaceAll(">", "&gt;");
14
+ }
15
+ export function renderLaunchdPlist(input) {
16
+ const env = [
17
+ ["IMA2_SERVICE", "1"],
18
+ ["PATH", input.pathEnv],
19
+ ];
20
+ if (input.configDir)
21
+ env.push(["IMA2_CONFIG_DIR", input.configDir]);
22
+ const envXml = env
23
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key><string>${xmlEscape(v)}</string>`)
24
+ .join("\n");
25
+ return `<?xml version="1.0" encoding="UTF-8"?>
26
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
27
+ <plist version="1.0">
28
+ <dict>
29
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
30
+ <key>ProgramArguments</key>
31
+ <array>
32
+ <string>${xmlEscape(input.nodePath)}</string>
33
+ <string>${xmlEscape(input.serverJs)}</string>
34
+ </array>
35
+ <key>WorkingDirectory</key><string>${xmlEscape(input.rootDir)}</string>
36
+ <key>RunAtLoad</key><true/>
37
+ <key>KeepAlive</key><true/>
38
+ <key>StandardOutPath</key><string>${xmlEscape(input.logDir)}/service.out.log</string>
39
+ <key>StandardErrorPath</key><string>${xmlEscape(input.logDir)}/service.err.log</string>
40
+ <key>EnvironmentVariables</key>
41
+ <dict>
42
+ ${envXml}
43
+ </dict>
44
+ </dict>
45
+ </plist>
46
+ `;
47
+ }
48
+ export function renderSystemdUnit(input) {
49
+ const lines = [
50
+ "[Unit]",
51
+ "Description=ima2-gen local generation server",
52
+ "After=network.target",
53
+ "",
54
+ "[Service]",
55
+ `ExecStart=${input.nodePath} ${input.serverJs}`,
56
+ `WorkingDirectory=${input.rootDir}`,
57
+ "Restart=always",
58
+ "RestartSec=2",
59
+ "Environment=IMA2_SERVICE=1",
60
+ `Environment=PATH=${input.pathEnv}`,
61
+ ];
62
+ if (input.configDir)
63
+ lines.push(`Environment=IMA2_CONFIG_DIR=${input.configDir}`);
64
+ lines.push("", "[Install]", "WantedBy=default.target", "");
65
+ return lines.join("\n");
66
+ }
67
+ /**
68
+ * launchctl's trap: `load` can exit 0 while writing "Load failed: ..." to
69
+ * stderr (opencodex hit this in production). Treat that stderr shape as a
70
+ * failure regardless of exit status.
71
+ */
72
+ export function launchctlOutputIndicatesFailure(stderr) {
73
+ const s = (stderr || "").toLowerCase();
74
+ return s.includes("load failed") || s.includes("bootstrap failed") || s.includes("input/output error");
75
+ }
76
+ /** Paths drifted (nvm/prefix/config-dir move) since install? repair re-renders. */
77
+ export function serviceStateStale(state, current) {
78
+ const issues = [];
79
+ if (state.nodePath !== current.nodePath)
80
+ issues.push(`node moved: ${state.nodePath} -> ${current.nodePath}`);
81
+ if (state.serverJs !== current.serverJs)
82
+ issues.push(`server.js moved: ${state.serverJs} -> ${current.serverJs}`);
83
+ if (current.configDir !== undefined && state.configDir !== current.configDir) {
84
+ issues.push(`config dir moved: ${state.configDir} -> ${current.configDir}`);
85
+ }
86
+ return issues;
87
+ }
package/docs/API.md CHANGED
@@ -31,6 +31,7 @@ Generation section below for the full endpoint specification.
31
31
  | Method | Path | Notes |
32
32
  |---|---|---|
33
33
  | `GET` | `/api/health` | Server health, version, paths, provider policy |
34
+ | `POST` | `/api/admin/stop` | Clean shutdown (local admin only): requires the boot-generated `X-Ima2-Admin-Nonce` from `~/.ima2/server.json`; any request with an `Origin` header is refused (browser drive-by protection). Responds `202` then self-signals SIGTERM |
34
35
  | `GET` | `/api/providers` | Provider availability and runtime ports |
35
36
  | `GET` | `/api/oauth/status` | OAuth proxy status and visible models |
36
37
  | `GET` | `/api/grok/status` | Bundled progrok status and visible xAI image models |
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 376 (runtime: 172, contract: 204)
7
+ Total: 378 (runtime: 174, contract: 204)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-mode-auto-planner-contract.test.ts`
@@ -146,6 +146,7 @@ Total: 376 (runtime: 172, contract: 204)
146
146
  - `tests/serve-singleton-contract.test.ts`
147
147
  - `tests/serve-ui-build-contract.test.ts`
148
148
  - `tests/server-code-preservation.test.ts`
149
+ - `tests/service-command-contract.test.ts`
149
150
  - `tests/skill-video-claims-contract.test.ts`
150
151
  - `tests/sprite-anchor-policy.test.ts`
151
152
  - `tests/sprite-atlas-compose.test.ts`
@@ -157,6 +158,7 @@ Total: 376 (runtime: 172, contract: 204)
157
158
  - `tests/sprite-recipe-routes.test.ts`
158
159
  - `tests/sprite-recipe-store.test.ts`
159
160
  - `tests/star-prompt.test.ts`
161
+ - `tests/stop-command-contract.test.ts`
160
162
  - `tests/storage-migration.test.ts`
161
163
  - `tests/structured-filename-pipelines.test.ts`
162
164
  - `tests/style-sheet.test.ts`
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Process-control helpers for `ima2 stop` (and service stop paths).
3
+ *
4
+ * Doctrine (adversarial audit 260821c): never kill a pid the advertise file
5
+ * merely CLAIMS — verify identity against the live /api/health response first,
6
+ * because pids get recycled. Graceful (admin API) before signals, SIGTERM
7
+ * before SIGKILL, and a stale advertise file is cleaned, not trusted.
8
+ */
9
+ import { execFileSync } from "node:child_process";
10
+ export function isProcessAlive(pid) {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /** Poll until the pid exits or the timeout lapses. CLI context: async is fine. */
20
+ export async function waitForExit(pid, timeoutMs) {
21
+ const deadline = Date.now() + timeoutMs;
22
+ while (Date.now() < deadline) {
23
+ if (!isProcessAlive(pid))
24
+ return true;
25
+ await new Promise((r) => setTimeout(r, 50));
26
+ }
27
+ return !isProcessAlive(pid);
28
+ }
29
+ /**
30
+ * Secondary identity signal for when the HTTP check is unreachable: compare
31
+ * the LIVE process start time against the advertised startedAt. A recycled
32
+ * pid belongs to a process started well after our server did; a hung-but-ours
33
+ * server started (approximately) when the advertise file says. Returns
34
+ * "corroborated" only when the start times agree within tolerance; "recycled"
35
+ * when the live process is provably younger than the advertised boot;
36
+ * "unknown" when ps output cannot be read — callers must REFUSE to kill on
37
+ * "unknown"/"recycled" (audit blocker: never guess).
38
+ */
39
+ export function corroborateByStartTime(pid, advertisedStartedAt, runPs = defaultPs) {
40
+ if (!advertisedStartedAt || !Number.isFinite(advertisedStartedAt))
41
+ return "unknown";
42
+ if (process.platform === "win32")
43
+ return "unknown";
44
+ const lstart = runPs(pid);
45
+ if (!lstart)
46
+ return "unknown";
47
+ const started = Date.parse(lstart);
48
+ if (!Number.isFinite(started))
49
+ return "unknown";
50
+ // advertise happens moments after process start; allow generous skew.
51
+ const TOLERANCE_MS = 120_000;
52
+ if (Math.abs(started - advertisedStartedAt) <= TOLERANCE_MS)
53
+ return "corroborated";
54
+ return started > advertisedStartedAt + TOLERANCE_MS ? "recycled" : "unknown";
55
+ }
56
+ function defaultPs(pid) {
57
+ try {
58
+ const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8" });
59
+ const line = out.trim();
60
+ return line.length > 0 ? line : null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Does the server answering on entry.url/port actually carry entry.pid?
68
+ * "mismatch" means someone else answers there (or the pid was recycled):
69
+ * killing entry.pid would hit an innocent process.
70
+ */
71
+ export async function verifyServerIdentity(entry, fetchFn = fetch) {
72
+ const base = (entry.url ?? (entry.port ? `http://127.0.0.1:${entry.port}` : null))?.toString().replace(/\/$/, "");
73
+ if (!base || !entry.pid)
74
+ return "unreachable";
75
+ try {
76
+ const controller = new AbortController();
77
+ const timer = setTimeout(() => controller.abort(), 1500);
78
+ const r = await fetchFn(`${base}/api/health`, {
79
+ signal: controller.signal,
80
+ headers: { connection: "close" },
81
+ });
82
+ clearTimeout(timer);
83
+ if (!r.ok)
84
+ return "unreachable";
85
+ const health = (await r.json());
86
+ return health.pid === entry.pid ? "match" : "mismatch";
87
+ }
88
+ catch {
89
+ return "unreachable";
90
+ }
91
+ }
92
+ /**
93
+ * Ask the server to stop itself via the admin API. Requires the nonce from the
94
+ * advertise file. Note: bin/lib/client.ts carries no LAN token, so on a
95
+ * token-guarded non-loopback bind this degrades (401) to the signal path —
96
+ * that is intended behavior, not an accident.
97
+ */
98
+ export async function gracefulStop(entry, fetchFn = fetch) {
99
+ const base = (entry.url ?? (entry.port ? `http://127.0.0.1:${entry.port}` : null))?.toString().replace(/\/$/, "");
100
+ if (!base || !entry.adminNonce)
101
+ return false;
102
+ try {
103
+ const controller = new AbortController();
104
+ const timer = setTimeout(() => controller.abort(), 2500);
105
+ const r = await fetchFn(`${base}/api/admin/stop`, {
106
+ method: "POST",
107
+ signal: controller.signal,
108
+ headers: { "x-ima2-admin-nonce": entry.adminNonce, connection: "close" },
109
+ });
110
+ clearTimeout(timer);
111
+ return r.status === 202;
112
+ }
113
+ catch {
114
+ return false;
115
+ }
116
+ }
117
+ /** SIGTERM → wait → SIGKILL → wait. Only ever called on an identity-verified pid. */
118
+ export async function escalateKill(pid, waits = {}) {
119
+ if (!isProcessAlive(pid))
120
+ return "already-dead";
121
+ try {
122
+ process.kill(pid, "SIGTERM");
123
+ }
124
+ catch {
125
+ return isProcessAlive(pid) ? "failed" : "already-dead";
126
+ }
127
+ if (await waitForExit(pid, waits.termMs ?? 5000))
128
+ return "term";
129
+ try {
130
+ process.kill(pid, "SIGKILL");
131
+ }
132
+ catch {
133
+ return isProcessAlive(pid) ? "failed" : "term";
134
+ }
135
+ if (await waitForExit(pid, waits.killMs ?? 2000))
136
+ return "kill";
137
+ return "failed";
138
+ }
@@ -120,6 +120,7 @@ export function createTestRuntimeContext(over = {}) {
120
120
  const base = {
121
121
  apiKey: undefined,
122
122
  apiKeySource: undefined,
123
+ adminNonce: "",
123
124
  config: {},
124
125
  grokActualPort: undefined,
125
126
  grokPort: 18645,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ima2-gen",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "packageManager": "npm@11.18.0",
5
5
  "description": "Local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.",
6
6
  "type": "module",
@@ -119,5 +119,5 @@
119
119
  "tsx": "^4.23.12",
120
120
  "typescript": "^5.9.3"
121
121
  },
122
- "gitHead": "3883482f57e132376dd7cd078a3f1e11791e94c0"
122
+ "gitHead": "b7369f8a4c042249dcaa282270421d0faa7ed4fe"
123
123
  }
@@ -0,0 +1,51 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { requireRuntimeContext } from "../lib/runtimeContext.js";
3
+ import { logEvent } from "../lib/logger.js";
4
+ /**
5
+ * Local admin surface. POST /api/admin/stop shuts the server down cleanly.
6
+ *
7
+ * Threat model: the LAN guard is a pass-through on loopback binds, so without
8
+ * extra auth ANY web page could fire a cross-origin
9
+ * fetch("http://127.0.0.1:3333/api/admin/stop") and kill the server — a
10
+ * remote-triggerable kill switch (adversarial audit 260821c, blocker 1).
11
+ * Two independent gates close that hole:
12
+ *
13
+ * 1. The caller must present the boot-generated admin nonce, which is
14
+ * published only in the advertise file (~/.ima2/server.json). Reading that
15
+ * file requires local filesystem access, which a web page does not have.
16
+ * 2. Any request carrying an Origin header is refused outright. Browser-issued
17
+ * cross-origin fetches always carry Origin; the ima2 CLI never does.
18
+ *
19
+ * Shutdown itself is a self-signal: the SIGTERM handler installed by
20
+ * onShutdown() owns the ONLY complete teardown (unadvertise, proxy children,
21
+ * agent queue, timers, DB close, exit) and its shutdownStarted latch makes the
22
+ * signal idempotent. Calling shutdownServerAndMcp() directly here would strand
23
+ * proxy children and leave a stale advertise file (audit blocker 2).
24
+ */
25
+ export function registerAdminRoutes(app, ctxRaw) {
26
+ const ctx = requireRuntimeContext(ctxRaw);
27
+ app.post("/api/admin/stop", (req, res) => {
28
+ if (typeof req.headers.origin === "string" && req.headers.origin.length > 0) {
29
+ return res.status(403).json({ error: "admin stop is not callable from a browser context" });
30
+ }
31
+ const nonce = req.headers["x-ima2-admin-nonce"];
32
+ const expected = Buffer.from(ctx.adminNonce);
33
+ const presented = typeof nonce === "string" ? Buffer.from(nonce) : Buffer.alloc(0);
34
+ const valid = expected.length > 0 &&
35
+ presented.length === expected.length &&
36
+ timingSafeEqual(presented, expected);
37
+ if (!valid) {
38
+ return res.status(401).json({ error: "missing or invalid admin nonce" });
39
+ }
40
+ logEvent("admin", "stop_requested", { pid: process.pid });
41
+ res.status(202).json({ ok: true, pid: process.pid, stopping: true });
42
+ setImmediate(() => {
43
+ try {
44
+ process.kill(process.pid, "SIGTERM");
45
+ }
46
+ catch {
47
+ process.exit(0);
48
+ }
49
+ });
50
+ });
51
+ }
package/routes/edit.js CHANGED
@@ -18,6 +18,8 @@ import { startJob, finishJob, registerJobAbortController, isJobCanceled, isStart
18
18
  import { isGenerationCanceledError, makeGenerationCanceledError, throwIfJobCanceled, } from "../lib/generationCancel.js";
19
19
  import { logEvent, logError } from "../lib/logger.js";
20
20
  import { hasPngAlphaChannel, parsePngInfo } from "../lib/pngInfo.js";
21
+ import { verifyBufferAlpha } from "../lib/imageBackgroundParam.js";
22
+ import { decodeRawForAlpha } from "../lib/alphaDecode.js";
21
23
  import { invalidateHistoryIndex } from "../lib/historyIndex.js";
22
24
  import { errInfo } from "../lib/errInfo.js";
23
25
  import { requireRuntimeContext } from "../lib/runtimeContext.js";
@@ -287,6 +289,21 @@ export function registerEditRoutes(app, ctxRaw) {
287
289
  const editExt = activeProvider === "grok" || activeProvider === "agy" || activeProvider === "grok-api" || activeProvider === "gemini-api" || activeProvider === "atlascloud" || activeProvider === "minimax" ? imageFormatFromMime(editMime) : "png";
288
290
  const editBuffer = Buffer.from(resultB64, "base64");
289
291
  const createdAt = Date.now();
292
+ // Semantic alpha verification: at least one pixel with alpha < 255.
293
+ // Never trust provider mime for transparency claims (same doctrine as
294
+ // the generate path); a failed decode reports alphaVerified: false.
295
+ let alphaVerified = false;
296
+ let alphaReason = null;
297
+ try {
298
+ const verdict = (await verifyBufferAlpha(editBuffer, decodeRawForAlpha));
299
+ alphaVerified = verdict.hasAlpha === true;
300
+ if (!alphaVerified)
301
+ alphaReason = verdict.reason ?? "undetectable";
302
+ }
303
+ catch {
304
+ alphaVerified = false;
305
+ alphaReason = "undetectable";
306
+ }
290
307
  const filename = await writeFileUnique(ctx.config.storage.generatedDir, buildFilename({
291
308
  model: (activeProvider === "grok" || activeProvider === "grok-api") ? resolveGrokQualityModel(imageModel, quality) : (imageModel || activeProvider),
292
309
  size: effectiveSize,
@@ -312,6 +329,8 @@ export function registerEditRoutes(app, ctxRaw) {
312
329
  kind: "edit",
313
330
  requestId,
314
331
  createdAt,
332
+ alphaVerified,
333
+ alphaReason,
315
334
  usage: usage || null,
316
335
  webSearchCalls,
317
336
  webSearchEnabled,
@@ -332,6 +351,8 @@ export function registerEditRoutes(app, ctxRaw) {
332
351
  elapsed,
333
352
  reasoningEffort,
334
353
  filename,
354
+ alphaVerified,
355
+ alphaReason,
335
356
  usage,
336
357
  provider: activeProvider,
337
358
  model: activeProvider === "grok" ? resolveGrokQualityModel(imageModel, quality) : imageModel,
package/routes/index.js CHANGED
@@ -2,6 +2,7 @@ import { registerCapabilitiesRoutes } from "./capabilities.js";
2
2
  import { registerModelsRoutes } from "./models.js";
3
3
  import { registerEventsRoute } from "./events.js";
4
4
  import { registerHealthRoutes } from "./health.js";
5
+ import { registerAdminRoutes } from "./admin.js";
5
6
  import { registerHistoryRoutes } from "./history.js";
6
7
  import { registerAssetsRoutes } from "./assets.js";
7
8
  import { registerSpriteRecipeRoutes } from "./spriteRecipes.js";
@@ -45,6 +46,7 @@ export function configureRoutes(app, ctxRaw) {
45
46
  const ctx = requireRuntimeContext(ctxRaw);
46
47
  registerEventsRoute(app, ctx);
47
48
  registerHealthRoutes(app, ctx);
49
+ registerAdminRoutes(app, ctx);
48
50
  registerCapabilitiesRoutes(app, ctx);
49
51
  registerStorageRoutes(app, ctx);
50
52
  registerMetadataRoutes(app, ctx);
package/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import "dotenv/config";
2
2
  import express from "express";
3
+ import { randomUUID } from "node:crypto";
3
4
  import { readFile } from "fs/promises";
4
- import { existsSync, writeFileSync, unlinkSync, mkdirSync, readFileSync as fsReadFileSync, } from "fs";
5
+ import { existsSync, writeFileSync, unlinkSync, chmodSync, mkdirSync, readFileSync as fsReadFileSync, } from "fs";
5
6
  import { dirname, join } from "path";
6
7
  import { fileURLToPath, pathToFileURL } from "url";
7
8
  import { onShutdown } from "./bin/lib/platform.js";
@@ -296,6 +297,7 @@ export function buildAdvertisePayload(ctx) {
296
297
  pid: process.pid,
297
298
  startedAt: ctx.startedAt,
298
299
  version: ctx.packageVersion,
300
+ adminNonce: ctx.adminNonce,
299
301
  backend: {
300
302
  configuredPort: Number(ctx.serverConfiguredPort || ctx.config.server.port),
301
303
  actualPort: Number(ctx.serverActualPort || ctx.config.server.port),
@@ -321,8 +323,14 @@ function advertise(ctx) {
321
323
  if (!ctx.serverActualPort)
322
324
  return;
323
325
  try {
324
- mkdirSync(dirname(ctx.config.storage.advertiseFile), { recursive: true });
325
- writeFileSync(ctx.config.storage.advertiseFile, JSON.stringify(buildAdvertisePayload(ctx)));
326
+ // The payload carries the admin nonce (a kill-switch credential): the file
327
+ // must be owner-only, or any local user on a shared host can stop the
328
+ // server (adversarial review 260821c, blocker 3).
329
+ mkdirSync(dirname(ctx.config.storage.advertiseFile), { recursive: true, mode: 0o700 });
330
+ writeFileSync(ctx.config.storage.advertiseFile, JSON.stringify(buildAdvertisePayload(ctx)), { mode: 0o600 });
331
+ // mode applies only at creation: a crash-survivor file from an older build
332
+ // keeps its old permissions, so re-assert them on every publish.
333
+ chmodSync(ctx.config.storage.advertiseFile, 0o600);
326
334
  }
327
335
  catch (e) {
328
336
  const err = errInfo(e);
@@ -379,6 +387,7 @@ export async function createRuntimeContext(overrides = {}) {
379
387
  openai,
380
388
  startedAt: overrides.startedAt ?? Date.now(),
381
389
  packageVersion: overrides.packageVersion ?? readPackageVersion(),
390
+ adminNonce: randomUUID(),
382
391
  xaiApiKey: loadedXaiKey.apiKey ?? undefined,
383
392
  xaiApiKeySource: loadedXaiKey.apiKeySource,
384
393
  hasXaiApiKey: !!loadedXaiKey.apiKey,
@@ -1,11 +1,11 @@
1
1
  {
2
- "_AssetGenWorkspace-CqXyN9at.js": {
3
- "file": "assets/AssetGenWorkspace-CqXyN9at.js",
2
+ "_AssetGenWorkspace-Bhi2OrfW.js": {
3
+ "file": "assets/AssetGenWorkspace-Bhi2OrfW.js",
4
4
  "name": "AssetGenWorkspace",
5
5
  "isDynamicEntry": true,
6
6
  "imports": [
7
7
  "index.html",
8
- "_KeyingPanel-Bv0n_bhk.js"
8
+ "_KeyingPanel-D7iozqcS.js"
9
9
  ],
10
10
  "dynamicImports": [
11
11
  "src/components/assetgen/SpriteRecipeWorkspace.tsx"
@@ -18,12 +18,12 @@
18
18
  "file": "assets/AssetGenWorkspace-Dkr80l12.css",
19
19
  "src": "_AssetGenWorkspace-Dkr80l12.css"
20
20
  },
21
- "_KeyingPanel-Bv0n_bhk.js": {
22
- "file": "assets/KeyingPanel-Bv0n_bhk.js",
21
+ "_KeyingPanel-D7iozqcS.js": {
22
+ "file": "assets/KeyingPanel-D7iozqcS.js",
23
23
  "name": "KeyingPanel",
24
24
  "imports": [
25
25
  "index.html",
26
- "_useAgentDialogFocus-R64NV-ZZ.js"
26
+ "_useAgentDialogFocus-CqjQmoEH.js"
27
27
  ]
28
28
  },
29
29
  "__vite-browser-external": {
@@ -32,15 +32,15 @@
32
32
  "src": "__vite-browser-external",
33
33
  "isDynamicEntry": true
34
34
  },
35
- "_useAgentDialogFocus-R64NV-ZZ.js": {
36
- "file": "assets/useAgentDialogFocus-R64NV-ZZ.js",
35
+ "_useAgentDialogFocus-CqjQmoEH.js": {
36
+ "file": "assets/useAgentDialogFocus-CqjQmoEH.js",
37
37
  "name": "useAgentDialogFocus",
38
38
  "imports": [
39
39
  "index.html"
40
40
  ]
41
41
  },
42
42
  "index.html": {
43
- "file": "assets/index-CxEuSZMC.js",
43
+ "file": "assets/index-CVVv0v-b.js",
44
44
  "name": "index",
45
45
  "src": "index.html",
46
46
  "isEntry": true,
@@ -55,16 +55,16 @@
55
55
  "src/components/card-news/CardNewsWorkspace.tsx",
56
56
  "src/components/agent/AgentWorkspace.tsx",
57
57
  "src/components/assets/AssetsWorkspace.tsx",
58
- "_AssetGenWorkspace-CqXyN9at.js",
58
+ "_AssetGenWorkspace-Bhi2OrfW.js",
59
59
  "src/components/home/HomeWorkspace.tsx",
60
60
  "src/components/PromptLibraryPanel.tsx"
61
61
  ],
62
62
  "css": [
63
- "assets/index-C0nfanMr.css"
63
+ "assets/index-C4IY6hxw.css"
64
64
  ]
65
65
  },
66
66
  "node_modules/pptxgenjs/dist/pptxgen.es.js": {
67
- "file": "assets/pptxgen.es-BE4CSd5F.js",
67
+ "file": "assets/pptxgen.es-DIQkG3ve.js",
68
68
  "name": "pptxgen.es",
69
69
  "src": "node_modules/pptxgenjs/dist/pptxgen.es.js",
70
70
  "isDynamicEntry": true,
@@ -78,7 +78,7 @@
78
78
  ]
79
79
  },
80
80
  "src/components/GenerationRequestLogPanel.tsx": {
81
- "file": "assets/GenerationRequestLogPanel-B68EUfUg.js",
81
+ "file": "assets/GenerationRequestLogPanel-sr-wSiOe.js",
82
82
  "name": "GenerationRequestLogPanel",
83
83
  "src": "src/components/GenerationRequestLogPanel.tsx",
84
84
  "isDynamicEntry": true,
@@ -87,7 +87,7 @@
87
87
  ]
88
88
  },
89
89
  "src/components/NodeCanvas.tsx": {
90
- "file": "assets/NodeCanvas-VwdctvAW.js",
90
+ "file": "assets/NodeCanvas-Bu6q2z3V.js",
91
91
  "name": "NodeCanvas",
92
92
  "src": "src/components/NodeCanvas.tsx",
93
93
  "isDynamicEntry": true,
@@ -99,7 +99,7 @@
99
99
  ]
100
100
  },
101
101
  "src/components/PromptImportDialog.tsx": {
102
- "file": "assets/PromptImportDialog-D4QYbRfH.js",
102
+ "file": "assets/PromptImportDialog-CZwFZDvz.js",
103
103
  "name": "PromptImportDialog",
104
104
  "src": "src/components/PromptImportDialog.tsx",
105
105
  "isDynamicEntry": true,
@@ -112,7 +112,7 @@
112
112
  ]
113
113
  },
114
114
  "src/components/PromptImportDiscoverySection.tsx": {
115
- "file": "assets/PromptImportDiscoverySection-Bx7BeTFh.js",
115
+ "file": "assets/PromptImportDiscoverySection-B5Lyhl9X.js",
116
116
  "name": "PromptImportDiscoverySection",
117
117
  "src": "src/components/PromptImportDiscoverySection.tsx",
118
118
  "isDynamicEntry": true,
@@ -121,7 +121,7 @@
121
121
  ]
122
122
  },
123
123
  "src/components/PromptImportFolderSection.tsx": {
124
- "file": "assets/PromptImportFolderSection-Bwf60URx.js",
124
+ "file": "assets/PromptImportFolderSection-DmMFtYmn.js",
125
125
  "name": "PromptImportFolderSection",
126
126
  "src": "src/components/PromptImportFolderSection.tsx",
127
127
  "isDynamicEntry": true,
@@ -130,7 +130,7 @@
130
130
  ]
131
131
  },
132
132
  "src/components/PromptLibraryPanel.tsx": {
133
- "file": "assets/PromptLibraryPanel-mNG2wZkQ.js",
133
+ "file": "assets/PromptLibraryPanel-BHr73ns_.js",
134
134
  "name": "PromptLibraryPanel",
135
135
  "src": "src/components/PromptLibraryPanel.tsx",
136
136
  "isDynamicEntry": true,
@@ -142,7 +142,7 @@
142
142
  ]
143
143
  },
144
144
  "src/components/SettingsWorkspace.tsx": {
145
- "file": "assets/SettingsWorkspace-DA5tIa0Q.js",
145
+ "file": "assets/SettingsWorkspace-DrQ44R-B.js",
146
146
  "name": "SettingsWorkspace",
147
147
  "src": "src/components/SettingsWorkspace.tsx",
148
148
  "isDynamicEntry": true,
@@ -151,43 +151,43 @@
151
151
  ]
152
152
  },
153
153
  "src/components/agent/AgentWorkspace.tsx": {
154
- "file": "assets/AgentWorkspace-fcIXiWV6.js",
154
+ "file": "assets/AgentWorkspace-DAOw-Q1x.js",
155
155
  "name": "AgentWorkspace",
156
156
  "src": "src/components/agent/AgentWorkspace.tsx",
157
157
  "isDynamicEntry": true,
158
158
  "imports": [
159
159
  "index.html",
160
- "_useAgentDialogFocus-R64NV-ZZ.js"
160
+ "_useAgentDialogFocus-CqjQmoEH.js"
161
161
  ]
162
162
  },
163
163
  "src/components/assetgen/SpriteRecipeWorkspace.tsx": {
164
- "file": "assets/SpriteRecipeWorkspace-Da9qTQX0.js",
164
+ "file": "assets/SpriteRecipeWorkspace-ChboDVCj.js",
165
165
  "name": "SpriteRecipeWorkspace",
166
166
  "src": "src/components/assetgen/SpriteRecipeWorkspace.tsx",
167
167
  "isDynamicEntry": true,
168
168
  "imports": [
169
169
  "index.html",
170
- "_AssetGenWorkspace-CqXyN9at.js",
171
- "_KeyingPanel-Bv0n_bhk.js",
172
- "_useAgentDialogFocus-R64NV-ZZ.js"
170
+ "_AssetGenWorkspace-Bhi2OrfW.js",
171
+ "_KeyingPanel-D7iozqcS.js",
172
+ "_useAgentDialogFocus-CqjQmoEH.js"
173
173
  ]
174
174
  },
175
175
  "src/components/assets/AssetsWorkspace.tsx": {
176
- "file": "assets/AssetsWorkspace-xwHFVOb7.js",
176
+ "file": "assets/AssetsWorkspace-D-pcyPvz.js",
177
177
  "name": "AssetsWorkspace",
178
178
  "src": "src/components/assets/AssetsWorkspace.tsx",
179
179
  "isDynamicEntry": true,
180
180
  "imports": [
181
181
  "index.html",
182
- "_KeyingPanel-Bv0n_bhk.js",
183
- "_useAgentDialogFocus-R64NV-ZZ.js"
182
+ "_KeyingPanel-D7iozqcS.js",
183
+ "_useAgentDialogFocus-CqjQmoEH.js"
184
184
  ],
185
185
  "css": [
186
186
  "assets/AssetsWorkspace-BkUjPPKU.css"
187
187
  ]
188
188
  },
189
189
  "src/components/canvas-mode/index.ts": {
190
- "file": "assets/index-Bv1Yrz8W.js",
190
+ "file": "assets/index-D1SD3LcV.js",
191
191
  "name": "index",
192
192
  "src": "src/components/canvas-mode/index.ts",
193
193
  "isDynamicEntry": true,
@@ -199,7 +199,7 @@
199
199
  ]
200
200
  },
201
201
  "src/components/card-news/CardNewsWorkspace.tsx": {
202
- "file": "assets/CardNewsWorkspace-ltdjG40R.js",
202
+ "file": "assets/CardNewsWorkspace-BETskmU7.js",
203
203
  "name": "CardNewsWorkspace",
204
204
  "src": "src/components/card-news/CardNewsWorkspace.tsx",
205
205
  "isDynamicEntry": true,
@@ -208,7 +208,7 @@
208
208
  ]
209
209
  },
210
210
  "src/components/home/HomeWorkspace.tsx": {
211
- "file": "assets/HomeWorkspace-BBOG_aNm.js",
211
+ "file": "assets/HomeWorkspace-vjN-A1AR.js",
212
212
  "name": "HomeWorkspace",
213
213
  "src": "src/components/home/HomeWorkspace.tsx",
214
214
  "isDynamicEntry": true,
@@ -217,7 +217,7 @@
217
217
  ]
218
218
  },
219
219
  "src/components/prompt-builder/PromptBuilderPanel.tsx": {
220
- "file": "assets/PromptBuilderPanel-BBMpF_3Q.js",
220
+ "file": "assets/PromptBuilderPanel-DklSQCS_.js",
221
221
  "name": "PromptBuilderPanel",
222
222
  "src": "src/components/prompt-builder/PromptBuilderPanel.tsx",
223
223
  "isDynamicEntry": true,