slidemind 0.2.4 → 0.3.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/graph-auth.mjs +865 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +69 -1
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
package/src/bridge/http-json.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
// Minimal JSON-over-HTTP transport shared by setup-claim redemption
|
|
2
|
-
// management. Validates real TLS certificates;
|
|
3
|
-
// self-signed HTTPS certificate or plain HTTP.
|
|
1
|
+
// Minimal JSON-over-HTTP transport shared by setup-claim redemption, session
|
|
2
|
+
// management and the Entra token endpoint. Validates real TLS certificates;
|
|
3
|
+
// loopback development may use a self-signed HTTPS certificate or plain HTTP.
|
|
4
|
+
//
|
|
5
|
+
// `form` covers OAuth's one deviation: the token endpoint answers JSON but
|
|
6
|
+
// insists on an x-www-form-urlencoded request body.
|
|
4
7
|
|
|
5
8
|
import http from "node:http";
|
|
6
9
|
import https from "node:https";
|
|
@@ -9,13 +12,19 @@ function isLoopbackHostname(hostname) {
|
|
|
9
12
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
10
13
|
}
|
|
11
14
|
|
|
12
|
-
export function httpJson(method, urlStr, { body = null, token = null } = {}) {
|
|
15
|
+
export function httpJson(method, urlStr, { body = null, form = null, token = null } = {}) {
|
|
13
16
|
const url = new URL(urlStr);
|
|
14
17
|
const transport = url.protocol === "https:" ? https : http;
|
|
15
|
-
const payload =
|
|
18
|
+
const payload = form != null
|
|
19
|
+
? new URLSearchParams(form).toString()
|
|
20
|
+
: body != null
|
|
21
|
+
? JSON.stringify(body)
|
|
22
|
+
: null;
|
|
16
23
|
const headers = { accept: "application/json" };
|
|
17
24
|
if (payload) {
|
|
18
|
-
headers["content-type"] =
|
|
25
|
+
headers["content-type"] = form != null
|
|
26
|
+
? "application/x-www-form-urlencoded"
|
|
27
|
+
: "application/json";
|
|
19
28
|
headers["content-length"] = Buffer.byteLength(payload);
|
|
20
29
|
}
|
|
21
30
|
if (token) headers.authorization = `Bearer ${token}`;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Credential containment for everything this process prints.
|
|
2
|
+
//
|
|
3
|
+
// The CLI holds four kinds of secret — the per-backend `sessionToken`, a
|
|
4
|
+
// Microsoft Graph refresh token, a short-lived Graph access token, and the
|
|
5
|
+
// one-time `smc1_…` setup claim. None of them is ever printed on purpose, and
|
|
6
|
+
// an audit found no path that does. What it did find is a repeating shape that
|
|
7
|
+
// could: a server-controlled RESPONSE body used verbatim as an error detail
|
|
8
|
+
// (`setup-claim.mjs` redeem failures, the Entra token endpoint, every non-2xx
|
|
9
|
+
// bridge call), plus `unknown command: <argv>` echoing a mis-pasted claim.
|
|
10
|
+
// Patching those four sites would leave the fifth one to the next route added.
|
|
11
|
+
//
|
|
12
|
+
// So the containment is value-based and lives at the output boundary instead:
|
|
13
|
+
// this process KNOWS its own secrets, so scrubbing is exact — no regex guessing
|
|
14
|
+
// at what "looks like a token", no false positives on ordinary text. Register a
|
|
15
|
+
// value when it enters memory; `bin/slidemind.mjs` wraps stdout/stderr once so
|
|
16
|
+
// every line, JSON payload and error message passes through `scrub()` on its
|
|
17
|
+
// way out.
|
|
18
|
+
//
|
|
19
|
+
// The replacement reveals ZERO characters of the secret but stays correlatable:
|
|
20
|
+
// the same credential always renders the same tag, so two occurrences in one
|
|
21
|
+
// message are recognisably the same thing — enough to debug with, useless to
|
|
22
|
+
// anyone reading over a shoulder or a pasted transcript.
|
|
23
|
+
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
|
|
26
|
+
// Below this length a value cannot be told apart from ordinary prose, and
|
|
27
|
+
// scrubbing it would corrupt unrelated output. Every real credential here is
|
|
28
|
+
// far longer; a test fixture that is not simply goes unscrubbed, which is why
|
|
29
|
+
// the containment tests use realistic lengths.
|
|
30
|
+
const MIN_SECRET_LENGTH = 8;
|
|
31
|
+
|
|
32
|
+
const secrets = new Set();
|
|
33
|
+
|
|
34
|
+
// Stable, non-reversible tag for one secret. Four hex characters of sha256 is
|
|
35
|
+
// ~65k buckets: plenty to tell two credentials apart in a single message, far
|
|
36
|
+
// too little to attack the value behind it.
|
|
37
|
+
export function tagFor(value) {
|
|
38
|
+
const hash = crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
39
|
+
return `«secret:${hash.slice(0, 4)}»`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add a live secret. Safe to call repeatedly with the same value (a rotating
|
|
43
|
+
// refresh token registers its replacement on every refresh, and the old one
|
|
44
|
+
// stays registered — a stale credential in a printed line is still a leak).
|
|
45
|
+
export function register(value) {
|
|
46
|
+
if (typeof value !== "string") return;
|
|
47
|
+
if (value.length < MIN_SECRET_LENGTH) return;
|
|
48
|
+
secrets.add(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Replace every registered secret in `text` with its tag. The common case is an
|
|
52
|
+
// empty set (nothing has been loaded yet) or a handful of entries — one session
|
|
53
|
+
// token plus at most two Graph tokens per connected backend — so this returns
|
|
54
|
+
// the input untouched rather than allocating when there is nothing to do.
|
|
55
|
+
export function scrub(text) {
|
|
56
|
+
if (secrets.size === 0) return text;
|
|
57
|
+
let out = text;
|
|
58
|
+
for (const secret of secrets) {
|
|
59
|
+
if (out.includes(secret)) out = out.replaceAll(secret, tagFor(secret));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Test-only: the containment suite drives several fake deployments in one
|
|
65
|
+
// process and must not have one case's credentials scrubbed out of another's
|
|
66
|
+
// output.
|
|
67
|
+
export function resetSecrets() {
|
|
68
|
+
secrets.clear();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// How many secrets are currently registered. Test-only introspection — nothing
|
|
72
|
+
// in the CLI branches on this.
|
|
73
|
+
export function secretCount() {
|
|
74
|
+
return secrets.size;
|
|
75
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
|
|
18
18
|
import { httpJson } from "./http-json.mjs";
|
|
19
|
+
import { register as registerSecret } from "./secrets.mjs";
|
|
19
20
|
|
|
20
21
|
export const SETUP_CLAIM_PREFIX = "smc1_";
|
|
21
22
|
|
|
@@ -69,6 +70,11 @@ export function decodeSetupClaim(raw) {
|
|
|
69
70
|
|
|
70
71
|
const secret = typeof parsed.s === "string" ? parsed.s : "";
|
|
71
72
|
if (!secret) throw invalid("this setup command carries no connection secret.");
|
|
73
|
+
// Both halves: the secret itself, and the whole claim — the blob is a
|
|
74
|
+
// container for the secret, so a message that echoes the claim (a redeem
|
|
75
|
+
// failure quoting the request, an argv echo) leaks it just as surely.
|
|
76
|
+
registerSecret(secret);
|
|
77
|
+
registerSecret(text);
|
|
72
78
|
|
|
73
79
|
let url;
|
|
74
80
|
try {
|
package/src/builders/build.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import fs from "node:fs";
|
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34
34
|
|
|
35
|
+
import { FAVICON_LINK } from "./gate-server.mjs";
|
|
35
36
|
import { lintSlidesOrThrow } from "./lint.mjs";
|
|
36
37
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
37
38
|
import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
|
|
@@ -195,7 +196,8 @@ const HTML_TEMPLATE = `<!doctype html>
|
|
|
195
196
|
<head>
|
|
196
197
|
<meta charset="utf-8" />
|
|
197
198
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
198
|
-
<title>__TITLE__</title>
|
|
199
|
+
<title>__TITLE__ — slidemind</title>
|
|
200
|
+
${FAVICON_LINK}
|
|
199
201
|
<!-- Arimo: open-source Arial-metric-compatible font. We alias it to
|
|
200
202
|
"Arial" so master text pinned to Arial gets a font with the same
|
|
201
203
|
horizontal extents PowerPoint/LibreOffice use, instead of macOS
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
import crypto from "node:crypto";
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const GATE_SESSION_PATH = path.join(__dirname, "gate-session.js");
|
|
16
20
|
|
|
17
21
|
// Tab-reuse timing contract, shared by both gates and injected into their
|
|
18
22
|
// overlays as CFG.pollMs / CFG.pollTtlMs:
|
|
@@ -20,6 +24,19 @@ import path from "node:path";
|
|
|
20
24
|
// open-browser grace window;
|
|
21
25
|
// POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
|
|
22
26
|
// polling only after the server side has certainly given up.
|
|
27
|
+
// One favicon for every page slidemind opens in a browser (gates, previews,
|
|
28
|
+
// sign-in): an inline data URI, so no server needs a /favicon.ico route and
|
|
29
|
+
// the static preview file stays self-contained.
|
|
30
|
+
export const FAVICON_LINK =
|
|
31
|
+
'<link rel="icon" href="data:image/svg+xml,' +
|
|
32
|
+
encodeURIComponent(
|
|
33
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">' +
|
|
34
|
+
'<rect width="32" height="32" rx="7" fill="#10131b"/>' +
|
|
35
|
+
'<rect x="6.5" y="9.5" width="19" height="13" rx="3" fill="none" stroke="#a9c2ff" stroke-width="2"/>' +
|
|
36
|
+
"</svg>",
|
|
37
|
+
) +
|
|
38
|
+
'">';
|
|
39
|
+
|
|
23
40
|
export const GRACE_MS = 1500;
|
|
24
41
|
export const POLL_MS = 1000;
|
|
25
42
|
export const POLL_TTL_MS = 35 * 60 * 1000;
|
|
@@ -77,6 +94,138 @@ export function serveStaticFile({ roots, pathname, res }) {
|
|
|
77
94
|
return false;
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
// Read a JSON POST body off a gate request and hand the parsed object to `cb`
|
|
98
|
+
// (an unparseable/absent body becomes `{}` — a gate normalizes its own fields
|
|
99
|
+
// anyway). Shared by every gate POST route (plan decision, plan backlog, and
|
|
100
|
+
// the preview gate's decision + backlog next), so the 5 MB flood guard lives in
|
|
101
|
+
// exactly one place. The guard matters: these servers bind loopback but any
|
|
102
|
+
// page in the user's browser can POST to a known localhost port.
|
|
103
|
+
export function readJsonBody(req, cb) {
|
|
104
|
+
// setEncoding matters: without it Node hands over Buffers, and `raw += chunk`
|
|
105
|
+
// stringifies each one on its own — a multi-byte character split across a
|
|
106
|
+
// chunk boundary (the plan gate's editedPlan is 50-60 KB of German markdown)
|
|
107
|
+
// comes out as U+FFFD. With the encoding set, the stream decoder holds the
|
|
108
|
+
// partial sequence back until the next chunk completes it.
|
|
109
|
+
req.setEncoding("utf8");
|
|
110
|
+
let raw = "";
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
req.on("data", (chunk) => {
|
|
113
|
+
raw += chunk;
|
|
114
|
+
// The flood guard counts BYTES, not UTF-16 code units — the limit is about
|
|
115
|
+
// memory, and non-ASCII text costs more bytes than it does string length.
|
|
116
|
+
bytes += Buffer.byteLength(chunk, "utf8");
|
|
117
|
+
if (bytes > 5e6) req.destroy();
|
|
118
|
+
});
|
|
119
|
+
req.on("end", () => {
|
|
120
|
+
let payload;
|
|
121
|
+
try {
|
|
122
|
+
payload = JSON.parse(raw || "{}");
|
|
123
|
+
} catch {
|
|
124
|
+
payload = {};
|
|
125
|
+
}
|
|
126
|
+
cb(payload && typeof payload === "object" ? payload : {});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// shared gate ROUTES + comment normalization
|
|
132
|
+
//
|
|
133
|
+
// Both gates ran byte-identical copies of the backlog route, the approve-409
|
|
134
|
+
// guard and the row-normalization preamble. One copy, two per-gate field shapes.
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
// The identity + text core every gate row shares: `id` dedupes a client retry
|
|
138
|
+
// of the same batch, `round` says which round the comment was written against
|
|
139
|
+
// (a queued row is delivered a round later, so the two can differ), and a row
|
|
140
|
+
// without text is dropped — an empty row prints as an instruction-free bullet.
|
|
141
|
+
// `fields(c)` contributes the gate's own anchor columns.
|
|
142
|
+
export function normalizeGateComments(raw, fields) {
|
|
143
|
+
if (!Array.isArray(raw)) return [];
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const c of raw) {
|
|
147
|
+
if (!c || typeof c !== "object") continue;
|
|
148
|
+
const text = typeof c.text === "string" ? c.text.trim() : "";
|
|
149
|
+
if (!text) continue;
|
|
150
|
+
const id = typeof c.id === "string" && c.id ? c.id : crypto.randomUUID();
|
|
151
|
+
if (seen.has(id)) continue;
|
|
152
|
+
seen.add(id);
|
|
153
|
+
out.push({
|
|
154
|
+
id,
|
|
155
|
+
round: Number.isFinite(c.round) ? Number(c.round) : 1,
|
|
156
|
+
...(fields ? fields(c) : {}),
|
|
157
|
+
text,
|
|
158
|
+
at: typeof c.at === "string" && c.at ? c.at : new Date().toISOString(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// The reloaded tab flushes its queued rows here. Non-empty ⇒ that run never
|
|
165
|
+
// parks: it resolves at once as a drain round. (The page only POSTs when it HAS
|
|
166
|
+
// a backlog, so the empty branch is just a defensive ack.)
|
|
167
|
+
//
|
|
168
|
+
// `isSuperseded` (the gate's settled check): once another tab's drain or
|
|
169
|
+
// decision has resolved this run, a late backlog must NOT be acked "drained" —
|
|
170
|
+
// the client would flip its rows to "sent" while gate.resolve is a no-op and
|
|
171
|
+
// the reviewer's comments silently vanish. "superseded" keeps them queued, and
|
|
172
|
+
// the tab's heartbeat poll carries them into the next run.
|
|
173
|
+
export function serveBacklogPost(req, res, { normalize, onDrained, isSuperseded = () => false }) {
|
|
174
|
+
readJsonBody(req, (payload) => {
|
|
175
|
+
const comments = normalize(payload.comments);
|
|
176
|
+
const state = !comments.length ? "parked" : isSuperseded() ? "superseded" : "drained";
|
|
177
|
+
res.writeHead(200, { "Content-Type": MIME[".json"] });
|
|
178
|
+
res.end(JSON.stringify({ ok: state !== "superseded", state }));
|
|
179
|
+
if (state === "drained") onDrained(comments);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The decision POST, with the shared 409 guard: once this run has settled —
|
|
184
|
+
// a backlog drained (the displayed round is superseded by comments the agent
|
|
185
|
+
// has already been told to work) or another tab's decision landed — a late
|
|
186
|
+
// decision of EITHER kind is refused. Acking a late "comments" with 200 would
|
|
187
|
+
// mark them sent while gate.resolve is a no-op; the 409 keeps them queued
|
|
188
|
+
// client-side for the next round.
|
|
189
|
+
export function serveDecisionPost(req, res, { normalize, isSuperseded, onDecision }) {
|
|
190
|
+
readJsonBody(req, (payload) => {
|
|
191
|
+
const decision = payload.decision === "comments" ? "comments" : "approve";
|
|
192
|
+
if (isSuperseded()) {
|
|
193
|
+
res.writeHead(409, { "Content-Type": MIME[".json"] });
|
|
194
|
+
res.end(JSON.stringify({ ok: false, error: "backlog_pending" }));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
res.writeHead(200, { "Content-Type": MIME[".json"] });
|
|
198
|
+
res.end(JSON.stringify({ ok: true }));
|
|
199
|
+
onDecision(decision, payload, decision === "comments" ? normalize(payload.comments) : []);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// browser bundling
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
// Turn a dependency-free ESM module's source into plain browser JS that runs
|
|
208
|
+
// inside a shared IIFE scope: drop single-line `import … from "…";` statements
|
|
209
|
+
// and strip the leading `export ` keyword from declarations. The imported names
|
|
210
|
+
// are provided by an earlier module in the concatenation, so removing the
|
|
211
|
+
// import line just leaves the reference resolving to that top-level binding.
|
|
212
|
+
export function stripModuleSyntax(src) {
|
|
213
|
+
return String(src || "")
|
|
214
|
+
.replace(/^\s*import\s+[^;]*?from\s*["'][^"']+["'];?\s*$/gm, "")
|
|
215
|
+
.replace(/^export\s+/gm, "");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// gate-session.js as a browser IIFE exposing window.__SLIDEMIND_GATE__. Both
|
|
219
|
+
// gate pages inline this BEFORE their overlay script — the overlays' whole
|
|
220
|
+
// ledger/backlog/round machinery is that one shared factory.
|
|
221
|
+
export function bundleGateSession() {
|
|
222
|
+
return (
|
|
223
|
+
"(function () {\n" +
|
|
224
|
+
stripModuleSyntax(fs.readFileSync(GATE_SESSION_PATH, "utf8")) +
|
|
225
|
+
"\nwindow.__SLIDEMIND_GATE__ = { createGateSession: createGateSession };\n})();\n"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
80
229
|
// The decision promise + timeout wrapper both gates block on. `resolve(value)`
|
|
81
230
|
// settles the internal promise once (a Promise never re-settles, so repeat calls
|
|
82
231
|
// are no-ops). `waitForDecision({ timeoutMs })` returns the promise directly when
|
|
@@ -91,13 +240,20 @@ export function createDecisionGate({
|
|
|
91
240
|
timeoutMessage = "review timed out — no decision received",
|
|
92
241
|
} = {}) {
|
|
93
242
|
let resolveDecision;
|
|
243
|
+
let isSettled = false;
|
|
94
244
|
const decisionPromise = new Promise((res) => {
|
|
95
245
|
resolveDecision = res;
|
|
96
246
|
});
|
|
97
247
|
return {
|
|
98
248
|
resolve(value) {
|
|
249
|
+
isSettled = true;
|
|
99
250
|
resolveDecision(value);
|
|
100
251
|
},
|
|
252
|
+
// Whether a decision/drain already resolved this run — the POST routes
|
|
253
|
+
// refuse late input on it, because a resolve after the first is a no-op.
|
|
254
|
+
settled() {
|
|
255
|
+
return isSettled;
|
|
256
|
+
},
|
|
101
257
|
waitForDecision({ timeoutMs } = {}) {
|
|
102
258
|
if (!timeoutMs) return decisionPromise;
|
|
103
259
|
return new Promise((resolve, reject) => {
|