slidemind 0.3.20 → 0.4.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 +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// Who may talk to the workspace API.
|
|
2
|
+
//
|
|
3
|
+
// Two callers, two credentials:
|
|
4
|
+
// CLI `Authorization: Bearer <cliBearer>` from the user-private descriptor.
|
|
5
|
+
// Browser the workspace's link key, which the CLI (bearer) puts in the URL
|
|
6
|
+
// fragment of every link it hands out (`#bootstrap=<key>`); the page
|
|
7
|
+
// exchanges it at POST /api/v1/session for an HttpOnly SameSite=Strict
|
|
8
|
+
// cookie and a CSRF token it must echo on every mutation.
|
|
9
|
+
//
|
|
10
|
+
// The link key is an HMAC under session.key: the same in every link and after
|
|
11
|
+
// every restart, so a link brings the user to its deck every time it is clicked,
|
|
12
|
+
// in any browser (an agent app's own pane included), with nothing to expire —
|
|
13
|
+
// the user's call, 2026-10-08: the workspace is one always-available thing, and
|
|
14
|
+
// once-only, minute-long tokens only added friction. They bought nothing: a
|
|
15
|
+
// 127.0.0.1 link is useless off this machine, and a program on it that can read
|
|
16
|
+
// the link can read session.key too. What keeps everyone else out is unchanged —
|
|
17
|
+
// another site or tab gets neither the key nor the cookie (SameSite=Strict, the
|
|
18
|
+
// Host check and `frame-ancestors 'self'` in server.mjs), and a local tool
|
|
19
|
+
// without the user's files holds neither the key nor the CLI bearer. Deleting
|
|
20
|
+
// session.key rotates the key and signs every browser out.
|
|
21
|
+
//
|
|
22
|
+
// With `storeDir` (the daemon passes its config dir) a browser session survives
|
|
23
|
+
// a daemon restart — the daemon restarts on a crash, a reboot and every CLI
|
|
24
|
+
// update, and a user must not be signed out by any of them. On disk:
|
|
25
|
+
// session.key 32 random bytes (0600); the link key, csrf and gate tokens are HMACs under it
|
|
26
|
+
// sessions.json sha256(cookie) → { expiresAt, lastSeen } (0600)
|
|
27
|
+
// A cookie is stored only as its hash, and the tokens derived from the key are
|
|
28
|
+
// recomputed, never stored. Sessions slide (400 days from the last use, the
|
|
29
|
+
// longest a browser keeps a cookie) and are capped at the 50 most recently used.
|
|
30
|
+
// Without `storeDir` everything lives in this process (tests).
|
|
31
|
+
|
|
32
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
|
|
36
|
+
import { authorizeBearer } from "./server.mjs";
|
|
37
|
+
|
|
38
|
+
export const COOKIE = "slidemind_ws";
|
|
39
|
+
// As long as a browser keeps a cookie at all (Chrome caps Max-Age at 400 days),
|
|
40
|
+
// sliding: a workspace the user opens at all never signs them out.
|
|
41
|
+
export const SESSION_TTL_MS = 400 * 24 * 60 * 60 * 1000;
|
|
42
|
+
export const SESSION_CAP = 50;
|
|
43
|
+
// lastSeen is written back at most this often per session: every request would
|
|
44
|
+
// otherwise rewrite the file
|
|
45
|
+
const TOUCH_MS = 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
const safeEqual = (a, b) => {
|
|
48
|
+
const x = Buffer.from(String(a ?? ""));
|
|
49
|
+
const y = Buffer.from(String(b ?? ""));
|
|
50
|
+
return x.length === y.length && x.length > 0 && timingSafeEqual(x, y);
|
|
51
|
+
};
|
|
52
|
+
const sha = (s) => createHash("sha256").update(String(s)).digest("hex");
|
|
53
|
+
|
|
54
|
+
function cookieValue(req, name) {
|
|
55
|
+
const header = String(req.headers.cookie ?? "");
|
|
56
|
+
for (const part of header.split(";")) {
|
|
57
|
+
const [k, ...v] = part.trim().split("=");
|
|
58
|
+
if (k === name) return v.join("=");
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The persisted HMAC key: read it, or create it once (a racing daemon's file wins). */
|
|
64
|
+
function loadKey(storeDir) {
|
|
65
|
+
const file = path.join(storeDir, "session.key");
|
|
66
|
+
const read = () => {
|
|
67
|
+
const k = fs.readFileSync(file);
|
|
68
|
+
return k.length === 32 ? k : null;
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
const k = read();
|
|
72
|
+
if (k) return k;
|
|
73
|
+
} catch {
|
|
74
|
+
// none yet
|
|
75
|
+
}
|
|
76
|
+
fs.mkdirSync(storeDir, { recursive: true, mode: 0o700 });
|
|
77
|
+
const key = randomBytes(32);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(file, key, { mode: 0o600, flag: "wx" });
|
|
80
|
+
return key;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (err.code !== "EEXIST") throw err;
|
|
83
|
+
}
|
|
84
|
+
const k = read();
|
|
85
|
+
if (k) return k;
|
|
86
|
+
// a truncated key file: replace it — every session it signed is unreadable anyway
|
|
87
|
+
fs.writeFileSync(file, key, { mode: 0o600 });
|
|
88
|
+
return key;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `createAuth({ bearer, storeDir = null })`
|
|
93
|
+
* linkToken() → { token } the link key (CLI, bearer-authenticated route)
|
|
94
|
+
* exchange(token) → { cookie, csrfToken } | null (the link key, as often as it is clicked)
|
|
95
|
+
* authenticate(req) → { kind: "cli" } | { kind: "browser", csrfToken } | null
|
|
96
|
+
* allowMutation(req, who, origin) → null | error code
|
|
97
|
+
* cookieHeader(cookie) → the Set-Cookie value (Max-Age = the session TTL)
|
|
98
|
+
* renewalHeader(req) → a Set-Cookie renewing the cookie when its session just slid, else null
|
|
99
|
+
* gateTokenFor(deckId) → the embedded gates' per-deck token (stable across restarts)
|
|
100
|
+
* gateTokenValid(token, deckId) → bool
|
|
101
|
+
*/
|
|
102
|
+
export function createAuth({ bearer, storeDir = null }) {
|
|
103
|
+
const key = storeDir ? loadKey(storeDir) : randomBytes(32);
|
|
104
|
+
const storeFile = storeDir ? path.join(storeDir, "sessions.json") : null;
|
|
105
|
+
// Two daemons on 127.0.0.1 share cookies by host, whatever their ports: the
|
|
106
|
+
// name carries the config dir so one workspace never overwrites another's.
|
|
107
|
+
const cookieName = storeDir ? `${COOKIE}_${sha(path.resolve(storeDir)).slice(0, 8)}` : COOKIE;
|
|
108
|
+
const mac = (s) => createHmac("sha256", key).update(s).digest("base64url");
|
|
109
|
+
const linkKey = mac("link");
|
|
110
|
+
const sessions = new Map(); // sha256(cookie) → { expiresAt, lastSeen }
|
|
111
|
+
let storeStamp = ""; // mtime:size of the file as last read or written
|
|
112
|
+
|
|
113
|
+
const load = () => {
|
|
114
|
+
if (!storeFile) return;
|
|
115
|
+
let st;
|
|
116
|
+
try {
|
|
117
|
+
st = fs.statSync(storeFile);
|
|
118
|
+
} catch {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (`${st.mtimeMs}:${st.size}` === storeStamp) return;
|
|
122
|
+
storeStamp = `${st.mtimeMs}:${st.size}`;
|
|
123
|
+
let data;
|
|
124
|
+
try {
|
|
125
|
+
data = JSON.parse(fs.readFileSync(storeFile, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return; // unreadable: start over rather than fail every request
|
|
128
|
+
}
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
for (const [h, s] of Object.entries(data?.sessions ?? {})) {
|
|
131
|
+
if (!s || !(s.expiresAt > now)) continue;
|
|
132
|
+
const mine = sessions.get(h);
|
|
133
|
+
if (!mine || mine.expiresAt < s.expiresAt) sessions.set(h, { expiresAt: s.expiresAt, lastSeen: s.lastSeen ?? 0 });
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const sweep = () => {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
for (const [h, s] of sessions) if (s.expiresAt <= now) sessions.delete(h);
|
|
139
|
+
if (sessions.size > SESSION_CAP) {
|
|
140
|
+
const oldest = [...sessions].sort(([, a], [, b]) => a.lastSeen - b.lastSeen);
|
|
141
|
+
for (const [h] of oldest.slice(0, sessions.size - SESSION_CAP)) sessions.delete(h);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
// Persisting is best effort: a session that cannot be written still works
|
|
145
|
+
// until this process ends, which is exactly the in-memory behaviour.
|
|
146
|
+
const save = () => {
|
|
147
|
+
if (!storeFile) return;
|
|
148
|
+
sweep();
|
|
149
|
+
const tmp = `${storeFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
150
|
+
try {
|
|
151
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions: Object.fromEntries(sessions) }), { mode: 0o600 });
|
|
152
|
+
fs.renameSync(tmp, storeFile);
|
|
153
|
+
const st = fs.statSync(storeFile);
|
|
154
|
+
storeStamp = `${st.mtimeMs}:${st.size}`;
|
|
155
|
+
} catch {
|
|
156
|
+
fs.rmSync(tmp, { force: true });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
// another daemon on this store (the one being replaced) may hold a newer state:
|
|
160
|
+
// merge it in before deciding, and before writing ours over it
|
|
161
|
+
const sync = () => {
|
|
162
|
+
load();
|
|
163
|
+
sweep();
|
|
164
|
+
};
|
|
165
|
+
load();
|
|
166
|
+
sweep();
|
|
167
|
+
|
|
168
|
+
const cookieHeader = (cookie) => `${cookieName}=${cookie}; Path=/; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}; HttpOnly; SameSite=Strict`;
|
|
169
|
+
// Requests whose session just slid: their response re-sends the cookie with a
|
|
170
|
+
// fresh Max-Age (renewalHeader), or the browser would drop it 30 days after
|
|
171
|
+
// the FIRST sign-in however often it was used.
|
|
172
|
+
const renewed = new WeakMap(); // req → cookie
|
|
173
|
+
/** The session behind this request's cookie, slid forward; null when there is none. */
|
|
174
|
+
const sessionOf = (req) => {
|
|
175
|
+
const c = cookieValue(req, cookieName);
|
|
176
|
+
if (!c) return null;
|
|
177
|
+
const h = sha(c);
|
|
178
|
+
let s = sessions.get(h);
|
|
179
|
+
if (!s || s.expiresAt <= Date.now()) {
|
|
180
|
+
sync(); // a session minted by the process that ran before us
|
|
181
|
+
s = sessions.get(h);
|
|
182
|
+
}
|
|
183
|
+
if (!s || s.expiresAt <= Date.now()) return null;
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
if (now - s.lastSeen > TOUCH_MS) {
|
|
186
|
+
s.lastSeen = now;
|
|
187
|
+
s.expiresAt = now + SESSION_TTL_MS;
|
|
188
|
+
load();
|
|
189
|
+
sessions.set(h, s);
|
|
190
|
+
save();
|
|
191
|
+
renewed.set(req, c);
|
|
192
|
+
}
|
|
193
|
+
return c;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
linkToken() {
|
|
198
|
+
return { token: linkKey };
|
|
199
|
+
},
|
|
200
|
+
exchange(token) {
|
|
201
|
+
if (!safeEqual(token, linkKey)) return null;
|
|
202
|
+
sync();
|
|
203
|
+
const cookie = randomBytes(32).toString("base64url");
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
sessions.set(sha(cookie), { expiresAt: now + SESSION_TTL_MS, lastSeen: now });
|
|
206
|
+
save();
|
|
207
|
+
return { cookie, csrfToken: mac(`csrf:${cookie}`) };
|
|
208
|
+
},
|
|
209
|
+
cookieHeader,
|
|
210
|
+
/**
|
|
211
|
+
* The Set-Cookie that renews this request's cookie (same value, a full
|
|
212
|
+
* Max-Age again) when its session just slid — at most once an hour per
|
|
213
|
+
* session; null otherwise. Call it for an authenticated browser request.
|
|
214
|
+
*/
|
|
215
|
+
renewalHeader(req) {
|
|
216
|
+
if (!renewed.has(req)) sessionOf(req);
|
|
217
|
+
const c = renewed.get(req);
|
|
218
|
+
return c ? cookieHeader(c) : null;
|
|
219
|
+
},
|
|
220
|
+
authenticate(req) {
|
|
221
|
+
if (authorizeBearer(req, bearer)) return { kind: "cli" };
|
|
222
|
+
const c = sessionOf(req);
|
|
223
|
+
return c ? { kind: "browser", csrfToken: mac(`csrf:${c}`) } : null;
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* A cookie-authenticated mutation must come from OUR page: the CSRF token
|
|
227
|
+
* echoed in `X-CSRF-Token`, and the browser's own origin evidence
|
|
228
|
+
* (`Origin`, else `Sec-Fetch-Site`) naming this authority. A request
|
|
229
|
+
* carrying neither is not a page of ours talking.
|
|
230
|
+
*/
|
|
231
|
+
allowMutation(req, who, origin) {
|
|
232
|
+
if (!who) return "unauthorized";
|
|
233
|
+
if (who.kind === "cli") return null;
|
|
234
|
+
if (!safeEqual(req.headers["x-csrf-token"], who.csrfToken)) return "csrf_required";
|
|
235
|
+
const o = req.headers.origin;
|
|
236
|
+
if (o !== undefined) return o === origin ? null : "cross_origin";
|
|
237
|
+
const site = req.headers["sec-fetch-site"];
|
|
238
|
+
if (site === "same-origin") return null;
|
|
239
|
+
return "cross_origin";
|
|
240
|
+
},
|
|
241
|
+
/** The token an embedded gate page carries in its decision URL; the same after a restart. */
|
|
242
|
+
gateTokenFor(deckId) {
|
|
243
|
+
return mac(`gate:${deckId}`);
|
|
244
|
+
},
|
|
245
|
+
gateTokenValid(token, deckId) {
|
|
246
|
+
return typeof deckId === "string" && deckId.length > 0 && safeEqual(token, mac(`gate:${deckId}`));
|
|
247
|
+
},
|
|
248
|
+
/** The cookie this workspace sets (scoped per config dir). */
|
|
249
|
+
cookieName,
|
|
250
|
+
/** Test/inspection seam: how many live browser sessions. */
|
|
251
|
+
get sessionCount() {
|
|
252
|
+
sweep();
|
|
253
|
+
return sessions.size;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The workspace's login item (doc 21 → *Launch and stop*): the one place a user
|
|
2
|
+
// answers must be running before any agent asks, because an agent in Codex's
|
|
3
|
+
// sandbox can neither start it nor keep a detached process (and, with network
|
|
4
|
+
// off, cannot even connect to it). `slidemind setup` registers this; it runs
|
|
5
|
+
// `slidemind workspace start` — a silent "start it unless it runs", never a
|
|
6
|
+
// supervisor: the daemon replaces itself by starting its successor and
|
|
7
|
+
// exiting, and a supervisor that restarts on exit would fight that.
|
|
8
|
+
//
|
|
9
|
+
// macOS LaunchAgent, at login and every 60 s (AbandonProcessGroup, so the
|
|
10
|
+
// detached daemon outlives the one-shot job)
|
|
11
|
+
// Windows a hidden launcher in the Startup folder, at login
|
|
12
|
+
// Linux an XDG autostart entry, at login
|
|
13
|
+
//
|
|
14
|
+
// Every function takes `{ home, platform, run }` so a test never touches the
|
|
15
|
+
// real login items of the machine it runs on.
|
|
16
|
+
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
export const LABEL = "com.statworx.slidemind.workspace";
|
|
23
|
+
const MAC_INTERVAL_S = 60;
|
|
24
|
+
|
|
25
|
+
const defaults = (o = {}) => ({
|
|
26
|
+
home: o.home ?? os.homedir(),
|
|
27
|
+
platform: o.platform ?? process.platform,
|
|
28
|
+
run: o.run ?? ((cmd, args) => execFileSync(cmd, args, { stdio: "ignore" })),
|
|
29
|
+
uid: o.uid ?? (typeof process.getuid === "function" ? process.getuid() : null),
|
|
30
|
+
env: o.env ?? process.env,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Where this platform keeps the login item; null where there is none. */
|
|
34
|
+
export function autostartPath(opts = {}) {
|
|
35
|
+
const { home, platform, env } = defaults(opts);
|
|
36
|
+
if (platform === "darwin") return path.join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
37
|
+
if (platform === "win32") return path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "slidemind-workspace.vbs");
|
|
38
|
+
if (platform === "linux") return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "autostart", "slidemind-workspace.desktop");
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const xml = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The login item's file for `node <script> workspace start`. `env` carries what
|
|
46
|
+
* a login job lacks: a PATH, and the config dir when the user moved it (the
|
|
47
|
+
* daemon must find THEIR registry and session key).
|
|
48
|
+
*/
|
|
49
|
+
export function autostartFile({ platform, nodePath, scriptPath, env = {}, home }) {
|
|
50
|
+
const args = [nodePath, scriptPath, "workspace", "start"];
|
|
51
|
+
if (platform === "darwin") {
|
|
52
|
+
const vars = Object.entries(env)
|
|
53
|
+
.filter(([, v]) => typeof v === "string" && v)
|
|
54
|
+
.map(([k, v]) => ` <key>${xml(k)}</key><string>${xml(v)}</string>\n`)
|
|
55
|
+
.join("");
|
|
56
|
+
return (
|
|
57
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
58
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
|
|
59
|
+
'<plist version="1.0">\n<dict>\n' +
|
|
60
|
+
` <key>Label</key><string>${LABEL}</string>\n` +
|
|
61
|
+
" <key>ProgramArguments</key>\n <array>\n" +
|
|
62
|
+
args.map((a) => ` <string>${xml(a)}</string>\n`).join("") +
|
|
63
|
+
" </array>\n" +
|
|
64
|
+
" <key>RunAtLoad</key><true/>\n" +
|
|
65
|
+
` <key>StartInterval</key><integer>${MAC_INTERVAL_S}</integer>\n` +
|
|
66
|
+
" <key>AbandonProcessGroup</key><true/>\n" +
|
|
67
|
+
" <key>ProcessType</key><string>Background</string>\n" +
|
|
68
|
+
` <key>WorkingDirectory</key><string>${xml(home)}</string>\n` +
|
|
69
|
+
" <key>StandardOutPath</key><string>/dev/null</string>\n" +
|
|
70
|
+
" <key>StandardErrorPath</key><string>/dev/null</string>\n" +
|
|
71
|
+
(vars ? ` <key>EnvironmentVariables</key>\n <dict>\n${vars} </dict>\n` : "") +
|
|
72
|
+
"</dict>\n</plist>\n"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (platform === "win32") {
|
|
76
|
+
// WScript.Shell.Run(cmd, 0 = hidden window, False = do not wait)
|
|
77
|
+
const cmd = args.map((a) => `""${a}""`).join(" ");
|
|
78
|
+
const sets = Object.entries(env)
|
|
79
|
+
.filter(([k, v]) => k !== "PATH" && typeof v === "string" && v)
|
|
80
|
+
.map(([k, v]) => `sh.Environment("Process")("${k}") = "${String(v).replace(/"/g, '""')}"\r\n`)
|
|
81
|
+
.join("");
|
|
82
|
+
return `Set sh = CreateObject("WScript.Shell")\r\n${sets}sh.Run "${cmd}", 0, False\r\n`;
|
|
83
|
+
}
|
|
84
|
+
if (platform === "linux") {
|
|
85
|
+
const quote = (a) => `"${String(a).replace(/(["`$\\])/g, "\\$1")}"`;
|
|
86
|
+
const envPrefix = Object.entries(env)
|
|
87
|
+
.filter(([k, v]) => k !== "PATH" && typeof v === "string" && v)
|
|
88
|
+
.map(([k, v]) => `${k}=${quote(v)}`);
|
|
89
|
+
const exec = envPrefix.length ? ["env", ...envPrefix, ...args.map(quote)].join(" ") : args.map(quote).join(" ");
|
|
90
|
+
return `[Desktop Entry]\nType=Application\nName=slidemind workspace\nComment=Keeps the slidemind workspace running\nExec=${exec}\nNoDisplay=true\nX-GNOME-Autostart-enabled=true\n`;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Register (or refresh) the login item and load it now. Resolves
|
|
97
|
+
* `{ installed, path, reason? }` — never throws: a machine where it cannot be
|
|
98
|
+
* registered still has a workspace started by setup and by every unsandboxed
|
|
99
|
+
* CLI run; the login item only makes it survive a reboot without one.
|
|
100
|
+
*/
|
|
101
|
+
export function installAutostart({ nodePath, scriptPath, env = {} }, opts = {}) {
|
|
102
|
+
const o = defaults(opts);
|
|
103
|
+
const file = autostartPath(o);
|
|
104
|
+
if (!file) return { installed: false, path: null, reason: `no login items on ${o.platform}` };
|
|
105
|
+
const body = autostartFile({ platform: o.platform, nodePath, scriptPath, env, home: o.home });
|
|
106
|
+
try {
|
|
107
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
108
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
109
|
+
fs.writeFileSync(tmp, body, { mode: 0o644 });
|
|
110
|
+
fs.renameSync(tmp, file);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return { installed: false, path: file, reason: err?.code ?? err?.message ?? String(err) };
|
|
113
|
+
}
|
|
114
|
+
if (o.platform === "darwin" && o.uid !== null) {
|
|
115
|
+
// reload: bootout of a job that is not loaded fails, and that is fine
|
|
116
|
+
const domain = `gui/${o.uid}`;
|
|
117
|
+
try {
|
|
118
|
+
o.run("launchctl", ["bootout", `${domain}/${LABEL}`]);
|
|
119
|
+
} catch {
|
|
120
|
+
// not loaded yet
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
o.run("launchctl", ["bootstrap", domain, file]);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return { installed: true, path: file, loaded: false, reason: `written; loads at the next login (${err?.message ?? err})` };
|
|
126
|
+
}
|
|
127
|
+
return { installed: true, path: file, loaded: true };
|
|
128
|
+
}
|
|
129
|
+
return { installed: true, path: file, loaded: false };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Remove the login item (and unload it on macOS). `{ removed, path }`; never throws. */
|
|
133
|
+
export function removeAutostart(opts = {}) {
|
|
134
|
+
const o = defaults(opts);
|
|
135
|
+
const file = autostartPath(o);
|
|
136
|
+
if (!file) return { removed: false, path: null };
|
|
137
|
+
if (o.platform === "darwin" && o.uid !== null) {
|
|
138
|
+
try {
|
|
139
|
+
o.run("launchctl", ["bootout", `gui/${o.uid}/${LABEL}`]);
|
|
140
|
+
} catch {
|
|
141
|
+
// not loaded
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
fs.rmSync(file);
|
|
146
|
+
return { removed: true, path: file };
|
|
147
|
+
} catch {
|
|
148
|
+
return { removed: false, path: file };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Is the login item registered? */
|
|
153
|
+
export function autostartInstalled(opts = {}) {
|
|
154
|
+
const file = autostartPath(opts);
|
|
155
|
+
return !!file && fs.existsSync(file);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A node path that survives an upgrade: the `node` on PATH when it is the very
|
|
160
|
+
* binary running now (Homebrew's /opt/homebrew/bin/node, not the versioned
|
|
161
|
+
* Cellar path the upgrade deletes), else this process's own.
|
|
162
|
+
*/
|
|
163
|
+
export function stableNodePath({ execPath = process.execPath, pathEnv = process.env.PATH ?? "", platform = process.platform } = {}) {
|
|
164
|
+
const real = (p) => {
|
|
165
|
+
try {
|
|
166
|
+
return fs.realpathSync(p);
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const mine = real(execPath);
|
|
172
|
+
const exe = platform === "win32" ? "node.exe" : "node";
|
|
173
|
+
for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
174
|
+
const candidate = path.join(dir, exe);
|
|
175
|
+
if (mine && real(candidate) === mine) return candidate;
|
|
176
|
+
}
|
|
177
|
+
return execPath;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The `slidemind` entry script a login item can rely on: the one on PATH (the
|
|
182
|
+
* global install `setup` just made), else the running one — unless that lives
|
|
183
|
+
* in npx's throw-away cache. Null when there is none.
|
|
184
|
+
*/
|
|
185
|
+
export function stableScriptPath({ running, pathEnv = process.env.PATH ?? "", platform = process.platform } = {}) {
|
|
186
|
+
const names = platform === "win32" ? ["slidemind.cmd", "slidemind"] : ["slidemind"];
|
|
187
|
+
for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
188
|
+
for (const n of names) {
|
|
189
|
+
const candidate = path.join(dir, n);
|
|
190
|
+
let target;
|
|
191
|
+
try {
|
|
192
|
+
target = fs.realpathSync(candidate);
|
|
193
|
+
} catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (target.endsWith(`${path.sep}slidemind.mjs`)) return target;
|
|
197
|
+
// an npm .cmd/shell shim: the package's own entry sits next to the shim's target package
|
|
198
|
+
const pkgEntry = path.join(path.dirname(candidate), "node_modules", "slidemind", "bin", "slidemind.mjs");
|
|
199
|
+
if (fs.existsSync(pkgEntry)) return pkgEntry;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (running && !/[\\/]_npx[\\/]/.test(running)) return running;
|
|
203
|
+
return null;
|
|
204
|
+
}
|