@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.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/README.md +48 -26
- package/ci/README.md +36 -9
- package/ci/openshare.js +547 -82
- package/dist/binding-queue.js +2 -2
- package/dist/commands.js +18 -15
- package/dist/config.d.ts +34 -15
- package/dist/config.js +127 -35
- package/dist/contract.d.ts +5 -5
- package/dist/contract.js +5 -5
- package/dist/core.d.ts +13 -1
- package/dist/core.js +491 -157
- package/dist/dialogs.d.ts +9 -10
- package/dist/dialogs.js +25 -28
- package/dist/redact.d.ts +46 -0
- package/dist/redact.js +130 -0
- package/dist/settings.d.ts +11 -8
- package/dist/settings.js +233 -91
- package/dist/share-flow.d.ts +89 -9
- package/dist/share-flow.js +172 -22
- package/dist/tui.js +449 -115
- package/dist/ui-prefs.js +6 -6
- package/dist/v2-server.js +5 -6
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/binding-queue.ts +2 -2
- package/src/commands.ts +18 -15
- package/src/config.ts +129 -32
- package/src/contract.ts +5 -5
- package/src/core.ts +468 -158
- package/src/dialogs.tsx +29 -32
- package/src/redact.ts +134 -0
- package/src/settings.ts +225 -83
- package/src/share-flow.ts +180 -23
- package/src/sidebar.tsx +7 -7
- package/src/tui.tsx +22 -24
- package/src/ui-prefs.ts +6 -6
- package/src/v2-server.ts +5 -6
package/dist/core.js
CHANGED
|
@@ -36,17 +36,19 @@
|
|
|
36
36
|
// Session metadata shape (metadata.openshare):
|
|
37
37
|
// - all-mode binding: { id, secret, rev, url }
|
|
38
38
|
// - manual-mode: { id, secret, rev, url, enabled: true, opts? }
|
|
39
|
-
// opts: {
|
|
39
|
+
// opts: { ttlHours?, authLogin?, authPassword?, modelLabel? } optional
|
|
40
40
|
// per-session overrides (capture at activation; modelLabel applies
|
|
41
41
|
// immediately, the rest at create/attach).
|
|
42
42
|
// - The plugin strips this key from everything it syncs — the write
|
|
43
43
|
// secret must never reach viewers.
|
|
44
44
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { homedir } from "node:os";
|
|
45
46
|
import { createHash } from "node:crypto";
|
|
46
|
-
import { join } from "node:path";
|
|
47
|
-
import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
47
|
+
import { isAbsolute, join } from "node:path";
|
|
48
|
+
import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
|
|
48
49
|
import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
|
|
49
50
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
51
|
+
import { activeRules, redactItem } from "./redact.js";
|
|
50
52
|
const DEFAULT_POLICY = {
|
|
51
53
|
mode: "persistent",
|
|
52
54
|
turnGraceMs: 120000,
|
|
@@ -56,9 +58,9 @@ const DEFAULT_POLICY = {
|
|
|
56
58
|
debounceMaxMs: Infinity,
|
|
57
59
|
completedDelayMaxMs: Infinity,
|
|
58
60
|
};
|
|
59
|
-
// Stderr policy
|
|
60
|
-
//
|
|
61
|
-
//
|
|
61
|
+
// Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
|
|
62
|
+
// only. Informational lines (live:/url file:/mode banner, lifecycle
|
|
63
|
+
// traces) are suppressed because the TUI surfaces the URL itself.
|
|
62
64
|
// All-mode (CI/server-only) keeps its stderr contract (announce lines,
|
|
63
65
|
// silenceable via announceStderr). Error-class lines always reach stderr.
|
|
64
66
|
let stderrInfoAllowed = true;
|
|
@@ -117,6 +119,22 @@ function redact(info) {
|
|
|
117
119
|
delete metadata[METADATA_KEY];
|
|
118
120
|
return { ...info, metadata };
|
|
119
121
|
}
|
|
122
|
+
/** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
|
|
123
|
+
* session item. Viewers recover them from the envelope's private block
|
|
124
|
+
* (gated+authenticated shares only); anonymous shares never store them. */
|
|
125
|
+
function stripLocalFacts(info) {
|
|
126
|
+
if (!info || typeof info !== "object")
|
|
127
|
+
return info;
|
|
128
|
+
const out = { ...info };
|
|
129
|
+
let changed = false;
|
|
130
|
+
for (const k of ["directory", "worktree"]) {
|
|
131
|
+
if (out[k] !== undefined) {
|
|
132
|
+
delete out[k];
|
|
133
|
+
changed = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return changed ? out : info;
|
|
137
|
+
}
|
|
120
138
|
function shareFlag(info) {
|
|
121
139
|
const raw = info?.metadata?.[METADATA_KEY];
|
|
122
140
|
if (!raw || typeof raw !== "object")
|
|
@@ -128,8 +146,11 @@ function shareFlag(info) {
|
|
|
128
146
|
const opts = {};
|
|
129
147
|
if (optsRaw && typeof optsRaw === "object") {
|
|
130
148
|
const o = optsRaw;
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
// legacy days-shaped opts migrate x24 (old bindings)
|
|
150
|
+
if (typeof o.ttlHours === "number" && o.ttlHours > 0)
|
|
151
|
+
opts.ttlHours = Math.floor(o.ttlHours);
|
|
152
|
+
else if (typeof o.ttlDays === "number" && o.ttlDays > 0)
|
|
153
|
+
opts.ttlHours = Math.floor(o.ttlDays * 24);
|
|
133
154
|
if (typeof o.authLogin === "string")
|
|
134
155
|
opts.authLogin = o.authLogin;
|
|
135
156
|
if (typeof o.authPassword === "string")
|
|
@@ -139,6 +160,28 @@ function shareFlag(info) {
|
|
|
139
160
|
}
|
|
140
161
|
return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
|
|
141
162
|
}
|
|
163
|
+
/** The LIVE engine's resolved target urls (shared module state: the TUI
|
|
164
|
+
* entry reads this so surfaces that gate on "is anything configured" agree
|
|
165
|
+
* with what the engine actually targets - file config alone can miss
|
|
166
|
+
* plugin-options-layer config the TUI cannot see). */
|
|
167
|
+
let liveTargets = [];
|
|
168
|
+
// the live engine registers its counter here (the TUI imports the
|
|
169
|
+
// accessor without holding the engine instance)
|
|
170
|
+
let statsProvider = null;
|
|
171
|
+
// Wave 3 preflight ledger: per-session redaction hits (count + first
|
|
172
|
+
// samples, verbatim, so false positives are visible in the consent
|
|
173
|
+
// preview before anything is published)
|
|
174
|
+
const redactionLedger = new Map();
|
|
175
|
+
/** Preflight stats for the consent dialog: message/tool counts plus the
|
|
176
|
+
* redaction ledger (what would be stripped before publishing). */
|
|
177
|
+
export function sessionStatsFor(sid) {
|
|
178
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
|
|
179
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
|
|
180
|
+
return { ...counts, redaction: led };
|
|
181
|
+
}
|
|
182
|
+
export function engineTargetUrls() {
|
|
183
|
+
return liveTargets;
|
|
184
|
+
}
|
|
142
185
|
export function createEngine(deps) {
|
|
143
186
|
let cfg = loadConfig(deps.pluginDir, deps.options);
|
|
144
187
|
const mode = resolveShareMode(cfg, deps);
|
|
@@ -173,8 +216,16 @@ export function createEngine(deps) {
|
|
|
173
216
|
log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
|
|
174
217
|
const resolveTargets = () => {
|
|
175
218
|
const merged = {};
|
|
176
|
-
|
|
219
|
+
const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
|
|
220
|
+
// layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
|
|
221
|
+
// would silently dual-home the session; an ENV workerUrl is the
|
|
222
|
+
// highest-priority layer and always joins.
|
|
223
|
+
const envWorker = !!process.env.OPENSHARE_WORKER_URL;
|
|
224
|
+
if (cfg.workerUrl && (!hasServers || envWorker))
|
|
177
225
|
merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
|
|
226
|
+
if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
|
|
227
|
+
log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
|
|
228
|
+
}
|
|
178
229
|
if (cfg.servers)
|
|
179
230
|
for (const [u, e] of Object.entries(cfg.servers))
|
|
180
231
|
merged[u] = { ...merged[u], ...e };
|
|
@@ -184,6 +235,7 @@ export function createEngine(deps) {
|
|
|
184
235
|
return chosen.map((url) => ({ url, entry: merged[url] }));
|
|
185
236
|
};
|
|
186
237
|
let targets = resolveTargets();
|
|
238
|
+
liveTargets = targets.map((t) => t.url);
|
|
187
239
|
// ---- per-server credentials (identity) ----------------------------------
|
|
188
240
|
// Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
|
|
189
241
|
// forever (server-side bootstrap); an osk_ user token is the normal
|
|
@@ -215,42 +267,116 @@ export function createEngine(deps) {
|
|
|
215
267
|
};
|
|
216
268
|
seedCredentials();
|
|
217
269
|
const ENROLL_RETRY_MS = 60000;
|
|
270
|
+
// B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
|
|
271
|
+
// server per boot, adopt ONLY keys with no explicit local source, and
|
|
272
|
+
// persist the adoption to the settings home (owner: no repeated CF
|
|
273
|
+
// fetches). One flag per URL for the whole process lifetime.
|
|
274
|
+
const setsFetched = new Set();
|
|
275
|
+
const adoptServerSuggestions = async (url, token) => {
|
|
276
|
+
if (setsFetched.has(url))
|
|
277
|
+
return;
|
|
278
|
+
setsFetched.add(url);
|
|
279
|
+
try {
|
|
280
|
+
const r = await fetch(`${url}/api/token/sets`, {
|
|
281
|
+
headers: { authorization: `Bearer ${token}` },
|
|
282
|
+
signal: AbortSignal.timeout(8000),
|
|
283
|
+
});
|
|
284
|
+
const body = r.ok ? (await r.json().catch(() => null)) : null;
|
|
285
|
+
const sets = body?.sets ?? {};
|
|
286
|
+
const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0];
|
|
287
|
+
if (!pick)
|
|
288
|
+
return;
|
|
289
|
+
const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel;
|
|
290
|
+
const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined;
|
|
291
|
+
// adopt only what is UNSPECIFIED locally: modelLabel sits at the
|
|
292
|
+
// default template, shareMode is unset and env carry no override
|
|
293
|
+
const src = configSourceMap(deps.pluginDir ?? "");
|
|
294
|
+
const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel;
|
|
295
|
+
const modeUnset = cfg.shareMode === undefined && !src.shareMode;
|
|
296
|
+
const adoptModel = wantModel && modelUnset;
|
|
297
|
+
const adoptMode = wantMode && modeUnset;
|
|
298
|
+
if (!adoptModel && !adoptMode)
|
|
299
|
+
return;
|
|
300
|
+
// surgical write into the settings home: read, patch just these
|
|
301
|
+
// keys when they are absent, write back (never clobber explicit
|
|
302
|
+
// values that appeared since load)
|
|
303
|
+
const home = configHomePath();
|
|
304
|
+
if (!home)
|
|
305
|
+
return;
|
|
306
|
+
let doc = {};
|
|
307
|
+
try {
|
|
308
|
+
doc = JSON.parse(readFileSync(home, "utf8"));
|
|
309
|
+
if (!doc || typeof doc !== "object")
|
|
310
|
+
doc = {};
|
|
311
|
+
}
|
|
312
|
+
catch { }
|
|
313
|
+
let dirty = false;
|
|
314
|
+
if (adoptModel && doc.modelLabel === undefined) {
|
|
315
|
+
doc.modelLabel = wantModel;
|
|
316
|
+
dirty = true;
|
|
317
|
+
log(`${url}: adopted modelLabel from the server config set`);
|
|
318
|
+
}
|
|
319
|
+
if (adoptMode && doc.shareMode === undefined) {
|
|
320
|
+
doc.shareMode = wantMode;
|
|
321
|
+
dirty = true;
|
|
322
|
+
log(`${url}: adopted shareMode from the server config set`);
|
|
323
|
+
}
|
|
324
|
+
if (dirty)
|
|
325
|
+
writeFileSync(home, JSON.stringify(doc, null, 2) + "\n");
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// suggestions are best-effort; a failed fetch never blocks sharing
|
|
329
|
+
}
|
|
330
|
+
};
|
|
218
331
|
const ensureCredential = async (url) => {
|
|
219
332
|
const c = credOf(url);
|
|
220
333
|
const target = targets.find((t) => t.url === url);
|
|
221
|
-
if (c.credential)
|
|
334
|
+
if (c.credential) {
|
|
335
|
+
void adoptServerSuggestions(url, c.credential);
|
|
222
336
|
return c.credential;
|
|
337
|
+
}
|
|
223
338
|
if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
|
|
224
339
|
return "";
|
|
225
340
|
c.lastEnrollAt = Date.now();
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (b?.ok && typeof b.token === "string") {
|
|
240
|
-
c.credential = b.token;
|
|
241
|
-
c.error = "";
|
|
242
|
-
const savedTo = saveServerToken(url, c.credential);
|
|
243
|
-
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
|
|
244
|
-
return c.credential;
|
|
341
|
+
// A configured code IS expressed onboarding intent (headless-safe: CI
|
|
342
|
+
// carries it precisely to enroll). Without one, never auto-enroll:
|
|
343
|
+
// public servers share anonymously (the ask-once TUI flow offers an
|
|
344
|
+
// account), private ones refuse until the user connects via
|
|
345
|
+
// /openshare.
|
|
346
|
+
const code = target?.entry.code ?? "";
|
|
347
|
+
if (!code) {
|
|
348
|
+
try {
|
|
349
|
+
const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
|
|
350
|
+
const hb = h.ok ? (await h.json().catch(() => null)) : null;
|
|
351
|
+
if (hb?.auth?.mode === "public") {
|
|
352
|
+
c.error = ""; // anonymous standing - tokenless create works
|
|
353
|
+
return "";
|
|
245
354
|
}
|
|
246
|
-
c.error =
|
|
355
|
+
c.error = "private server - onboard via /openshare (needs an access code)";
|
|
247
356
|
}
|
|
248
|
-
|
|
249
|
-
c.error =
|
|
357
|
+
catch (e) {
|
|
358
|
+
c.error = `server unreachable: ${e?.message || e}`;
|
|
250
359
|
}
|
|
251
|
-
|
|
252
|
-
|
|
360
|
+
logError(`no credential for ${url}: ${c.error}`);
|
|
361
|
+
return "";
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const r = await fetch(`${url}/api/enroll`, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: { "content-type": "application/json" },
|
|
367
|
+
body: JSON.stringify({ code }),
|
|
368
|
+
signal: AbortSignal.timeout(10000),
|
|
369
|
+
});
|
|
370
|
+
const b = r.ok ? (await r.json().catch(() => null)) : null;
|
|
371
|
+
if (b?.ok && typeof b.token === "string") {
|
|
372
|
+
c.credential = b.token;
|
|
373
|
+
c.error = "";
|
|
374
|
+
const savedTo = saveServerToken(url, c.credential);
|
|
375
|
+
log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
|
|
376
|
+
void adoptServerSuggestions(url, c.credential);
|
|
377
|
+
return c.credential;
|
|
253
378
|
}
|
|
379
|
+
c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
|
|
254
380
|
}
|
|
255
381
|
catch (e) {
|
|
256
382
|
c.error = `enrollment failed: ${e?.message || e}`;
|
|
@@ -385,7 +511,8 @@ export function createEngine(deps) {
|
|
|
385
511
|
badSignal: false,
|
|
386
512
|
lastSentStatus: null,
|
|
387
513
|
closed: false,
|
|
388
|
-
|
|
514
|
+
lastError: null,
|
|
515
|
+
ttlClamped: null,
|
|
389
516
|
binding: null,
|
|
390
517
|
bindingQueried: false,
|
|
391
518
|
optOuts: new Set(),
|
|
@@ -406,6 +533,7 @@ export function createEngine(deps) {
|
|
|
406
533
|
spoolRoot = spoolDirFor(cfg);
|
|
407
534
|
const before = new Set(targets.map((t) => t.url));
|
|
408
535
|
targets = resolveTargets();
|
|
536
|
+
liveTargets = targets.map((t) => t.url);
|
|
409
537
|
seedCredentials();
|
|
410
538
|
clampToPolicy();
|
|
411
539
|
if (debugEvents) {
|
|
@@ -619,13 +747,17 @@ export function createEngine(deps) {
|
|
|
619
747
|
for (const url of st.optOuts)
|
|
620
748
|
if (!servers[url])
|
|
621
749
|
servers[url] = { optedOut: true };
|
|
622
|
-
// A binding in session metadata means opted-in
|
|
623
|
-
// modes
|
|
624
|
-
// gate fall through to the consent dialog on an already-shared
|
|
625
|
-
// live is
|
|
750
|
+
// A binding in session metadata means opted-in, so enabled: true is
|
|
751
|
+
// written in BOTH modes. All-mode bindings without `enabled` made the
|
|
752
|
+
// TUI gate fall through to the consent dialog on an already-shared
|
|
753
|
+
// session; live is uniformly "url && enabled !== false".
|
|
626
754
|
const value = { v: 2, servers, enabled: true };
|
|
627
755
|
if (mode === "manual" && st.opts)
|
|
628
756
|
value.opts = st.opts;
|
|
757
|
+
if (st.lastError)
|
|
758
|
+
value.lastError = st.lastError;
|
|
759
|
+
if (st.ttlClamped)
|
|
760
|
+
value.ttlClamped = st.ttlClamped;
|
|
629
761
|
st.binding = value;
|
|
630
762
|
st.wroteBinding = true;
|
|
631
763
|
await writeMetadata(st, value);
|
|
@@ -675,9 +807,9 @@ export function createEngine(deps) {
|
|
|
675
807
|
appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
|
|
676
808
|
}
|
|
677
809
|
catch (e) {
|
|
678
|
-
// the frame lives ONLY in memory now
|
|
679
|
-
// spoolLoad
|
|
680
|
-
//
|
|
810
|
+
// the frame lives ONLY in memory now, so flag the remote: the next
|
|
811
|
+
// spoolLoad must NOT replace in-memory pending with the shorter disk
|
|
812
|
+
// queue (silent loss otherwise).
|
|
681
813
|
r.spoolAppendFailed = true;
|
|
682
814
|
logError("spool append failed", e?.message || e);
|
|
683
815
|
}
|
|
@@ -700,10 +832,10 @@ export function createEngine(deps) {
|
|
|
700
832
|
};
|
|
701
833
|
const spoolLoad = (st, r) => {
|
|
702
834
|
// meta first (share binding recovery), in its OWN try: a missing meta
|
|
703
|
-
// file is normal (
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
//
|
|
835
|
+
// file is normal (written only after the first successful handshake)
|
|
836
|
+
// and must NOT touch pending. A blanket catch here wipes in-memory
|
|
837
|
+
// pending AND skips the queue read, so everything queued before the
|
|
838
|
+
// first successful connect is lost.
|
|
707
839
|
try {
|
|
708
840
|
const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
|
|
709
841
|
// metadata binding (db) is authoritative when both exist and disagree
|
|
@@ -887,27 +1019,99 @@ export function createEngine(deps) {
|
|
|
887
1019
|
// Append: CI reads the file after each run; keeping every share
|
|
888
1020
|
// URL lets one file serve a whole workflow (the legacy original
|
|
889
1021
|
// overwrote with the latest only).
|
|
890
|
-
const file = cfg.urlFile
|
|
1022
|
+
const file = cfg.urlFile
|
|
1023
|
+
? isAbsolute(cfg.urlFile)
|
|
1024
|
+
? cfg.urlFile
|
|
1025
|
+
: join(deps.directory ?? process.cwd(), cfg.urlFile)
|
|
1026
|
+
: join(deps.directory ?? process.cwd(), "openshare.urls.txt");
|
|
891
1027
|
try {
|
|
892
|
-
|
|
1028
|
+
// "json" format: one machine-readable object per line (CI parses
|
|
1029
|
+
// fields instead of URLs). Status is the engine's current signal
|
|
1030
|
+
// at announce time; the completed flip updates the SHARE, not
|
|
1031
|
+
// this file line.
|
|
1032
|
+
const line = cfg.urlFileFormat === "json"
|
|
1033
|
+
? JSON.stringify({
|
|
1034
|
+
id: r.shareId,
|
|
1035
|
+
url,
|
|
1036
|
+
status: st.lastSentStatus ?? "waiting",
|
|
1037
|
+
gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
|
|
1038
|
+
}) + "\n"
|
|
1039
|
+
: url + "\n";
|
|
1040
|
+
appendFileSync(file, line);
|
|
893
1041
|
if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
|
|
894
1042
|
urlFileAnnounced = true;
|
|
895
1043
|
lastUrlFile = file;
|
|
896
1044
|
log(`url file: ${file}`);
|
|
897
1045
|
}
|
|
898
1046
|
}
|
|
899
|
-
catch {
|
|
1047
|
+
catch (e) {
|
|
1048
|
+
// a CI pipeline reads this file after the process exits; a silent
|
|
1049
|
+
// failure means the run's share URL is simply gone
|
|
1050
|
+
logError(`url file write failed (${file}):`, e?.message || e);
|
|
1051
|
+
}
|
|
900
1052
|
}
|
|
901
1053
|
};
|
|
1054
|
+
let halfAuthWarned = false;
|
|
902
1055
|
const authFrame = (st) => {
|
|
903
1056
|
// always send both (empty strings included): attach with empty values
|
|
904
|
-
// REMOVES protection
|
|
1057
|
+
// REMOVES protection - an intentional owner action. Session opts
|
|
905
1058
|
// override the global config when set.
|
|
906
1059
|
const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
|
|
907
1060
|
const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
|
|
1061
|
+
// both-or-none: the server builds a gate only from a full pair, so a
|
|
1062
|
+
// half pair silently means "no gate" - refuse to send one and say so
|
|
1063
|
+
if ((login === "") !== (password === "")) {
|
|
1064
|
+
if (!halfAuthWarned) {
|
|
1065
|
+
halfAuthWarned = true;
|
|
1066
|
+
logError("read auth needs BOTH a login and a password - gate disabled until fixed");
|
|
1067
|
+
}
|
|
1068
|
+
return { authLogin: "", authPassword: "" };
|
|
1069
|
+
}
|
|
908
1070
|
return { authLogin: login, authPassword: password };
|
|
909
1071
|
};
|
|
910
|
-
const ttlFor = (st) => st.opts?.
|
|
1072
|
+
const ttlFor = (st) => st.opts?.ttlHours ?? cfg.ttlHours;
|
|
1073
|
+
// free-form labels for team directories/admin listings: ${NAME} refs
|
|
1074
|
+
// resolve from the process env at send time; unresolved refs stay
|
|
1075
|
+
// literal (visible beats silently-empty). Cached env lookup, max 20
|
|
1076
|
+
// keys/500 chars per value (config loader caps the same).
|
|
1077
|
+
const metaFor = () => {
|
|
1078
|
+
if (!cfg.meta || typeof cfg.meta !== "object")
|
|
1079
|
+
return undefined;
|
|
1080
|
+
const out = {};
|
|
1081
|
+
let n = 0;
|
|
1082
|
+
for (const [k, v] of Object.entries(cfg.meta)) {
|
|
1083
|
+
if (n >= 20)
|
|
1084
|
+
break;
|
|
1085
|
+
out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m);
|
|
1086
|
+
n++;
|
|
1087
|
+
}
|
|
1088
|
+
return Object.keys(out).length ? out : undefined;
|
|
1089
|
+
};
|
|
1090
|
+
/** Apply a metadata opts update. On a CHANGED opts set for a session with
|
|
1091
|
+
* live sockets, re-send the attach frame over each open socket: the
|
|
1092
|
+
* worker's attach path applies read-auth retroactively (rotating viewer
|
|
1093
|
+
* tokens on change) and now also honors an explicit ttlHours override -
|
|
1094
|
+
* so settings heal immediately instead of waiting for a reconnect.
|
|
1095
|
+
* Re-attach is idempotent (no once-guard on init; earlySyncs empty at
|
|
1096
|
+
* steady state) and revalidates the token. */
|
|
1097
|
+
const applyOpts = (st, opts) => {
|
|
1098
|
+
if (!opts)
|
|
1099
|
+
return;
|
|
1100
|
+
const prev = st.opts ? JSON.stringify(st.opts) : "";
|
|
1101
|
+
st.opts = opts;
|
|
1102
|
+
if (prev === JSON.stringify(opts))
|
|
1103
|
+
return;
|
|
1104
|
+
for (const [url, r] of st.remotes) {
|
|
1105
|
+
if (!r.shareId || !r.secret || !r.wsReady)
|
|
1106
|
+
continue;
|
|
1107
|
+
const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
|
|
1108
|
+
if (st.opts?.ttlHours)
|
|
1109
|
+
attach.ttlHours = st.opts.ttlHours;
|
|
1110
|
+
const sent = wsSend(r, { op: "init", attach });
|
|
1111
|
+
if (sent)
|
|
1112
|
+
log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
911
1115
|
/** Best-effort cleanup of a share we are about to abandon (refused
|
|
912
1116
|
* attach, mismatch recreate). Prevents husk litter; failures are fine —
|
|
913
1117
|
* the TTL sweeper collects strays eventually. */
|
|
@@ -925,9 +1129,9 @@ export function createEngine(deps) {
|
|
|
925
1129
|
};
|
|
926
1130
|
/** Single-flight connect queuing: multiple paths (activation, event
|
|
927
1131
|
* reconnect, backfill chain) may want a socket before any connect has
|
|
928
|
-
* STARTED
|
|
929
|
-
* and double connects
|
|
930
|
-
*
|
|
1132
|
+
* STARTED. The connecting flag alone guards execution, not the queue,
|
|
1133
|
+
* and double connects create duplicate shares and init storms (one
|
|
1134
|
+
* create per socket). */
|
|
931
1135
|
const queueConnect = (st, r) => {
|
|
932
1136
|
if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
|
|
933
1137
|
return;
|
|
@@ -1000,7 +1204,7 @@ export function createEngine(deps) {
|
|
|
1000
1204
|
st.remotes.delete(url);
|
|
1001
1205
|
}
|
|
1002
1206
|
// collisions resolved by the TUI: the binding entry for the server
|
|
1003
|
-
// was removed
|
|
1207
|
+
// was removed - re-arm unless the same patch also opted out
|
|
1004
1208
|
for (const r of [...st.remotes.values()]) {
|
|
1005
1209
|
if (!r.collided || st.optOuts.has(r.url))
|
|
1006
1210
|
continue;
|
|
@@ -1016,18 +1220,41 @@ export function createEngine(deps) {
|
|
|
1016
1220
|
r.announced = false;
|
|
1017
1221
|
queueConnect(st, r);
|
|
1018
1222
|
}
|
|
1223
|
+
// re-share after "leave as-is": the entry kept its id/secret and the
|
|
1224
|
+
// opt-out marker is gone - re-arm a remote that ATTACHES the same
|
|
1225
|
+
// page (same URL), then backfill so the frozen gap catches up
|
|
1226
|
+
// (dedupe hashes describe the OLD remote set, not this revival)
|
|
1227
|
+
let rearmed = false;
|
|
1228
|
+
for (const [url, e] of Object.entries(servers)) {
|
|
1229
|
+
if (st.optOuts.has(url) || st.remotes.has(url))
|
|
1230
|
+
continue;
|
|
1231
|
+
const ee = e;
|
|
1232
|
+
if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
|
|
1233
|
+
continue;
|
|
1234
|
+
const r = remoteFor(st, url);
|
|
1235
|
+
r.shareId = ee.id;
|
|
1236
|
+
r.secret = ee.secret;
|
|
1237
|
+
r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
|
|
1238
|
+
log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
|
|
1239
|
+
queueConnect(st, r);
|
|
1240
|
+
rearmed = true;
|
|
1241
|
+
}
|
|
1242
|
+
if (rearmed) {
|
|
1243
|
+
st.sentHash.clear();
|
|
1244
|
+
chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
|
|
1245
|
+
}
|
|
1019
1246
|
}
|
|
1020
1247
|
catch { }
|
|
1021
1248
|
};
|
|
1022
1249
|
const connect = async (st, r) => {
|
|
1023
1250
|
if (st.closed || r.connecting || r.authBlocked || r.collided)
|
|
1024
1251
|
return;
|
|
1025
|
-
// "connecting" spans the WHOLE handshake: socket open
|
|
1026
|
-
// is only
|
|
1027
|
-
// event arriving mid-handshake must NEVER spawn a second socket
|
|
1028
|
-
// old connect() returned after merely SENDING init
|
|
1252
|
+
// "connecting" spans the WHOLE handshake: socket open plus init ack.
|
|
1253
|
+
// It is cleared only on init-ok, socket death or the init timeout. An
|
|
1254
|
+
// event arriving mid-handshake must NEVER spawn a second socket: the
|
|
1255
|
+
// old connect() returned after merely SENDING init, a second socket
|
|
1029
1256
|
// raced it, and the loser's late init-ok poisoned the winner's state
|
|
1030
|
-
//
|
|
1257
|
+
// (duplicate shares, mismatch storms, empty pages).
|
|
1031
1258
|
r.connecting = true;
|
|
1032
1259
|
r.socketSeq += 1;
|
|
1033
1260
|
const seq = r.socketSeq;
|
|
@@ -1044,9 +1271,9 @@ export function createEngine(deps) {
|
|
|
1044
1271
|
spoolLoad(st, r);
|
|
1045
1272
|
askPolicy(r.url);
|
|
1046
1273
|
// was this socket's init an ATTACH (resume) or a CREATE? A create
|
|
1047
|
-
// ack legitimately starts at rev 0
|
|
1048
|
-
// attaches only
|
|
1049
|
-
//
|
|
1274
|
+
// ack legitimately starts at rev 0, so the mismatch guard is for
|
|
1275
|
+
// attaches only. Applying it to a create spawns a fresh husk share
|
|
1276
|
+
// with the content dropped.
|
|
1050
1277
|
r.initWasAttach = !!(r.shareId && r.secret);
|
|
1051
1278
|
if (debugEvents)
|
|
1052
1279
|
log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
|
|
@@ -1140,7 +1367,7 @@ export function createEngine(deps) {
|
|
|
1140
1367
|
wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
|
|
1141
1368
|
}
|
|
1142
1369
|
else {
|
|
1143
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1370
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1144
1371
|
}
|
|
1145
1372
|
// handshake deadline: init sent but no ack (silent worker death)
|
|
1146
1373
|
// — close, let the close listener reconnect us
|
|
@@ -1277,8 +1504,10 @@ export function createEngine(deps) {
|
|
|
1277
1504
|
const err = String(frame.error ?? "");
|
|
1278
1505
|
if (/frozen/i.test(err)) {
|
|
1279
1506
|
// owner frozen: every control path is severed server-side.
|
|
1280
|
-
// Re-enrolling would orphan the shares
|
|
1507
|
+
// Re-enrolling would orphan the shares - surface and stop.
|
|
1281
1508
|
r.authBlocked = true;
|
|
1509
|
+
st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
|
|
1510
|
+
scheduleBindingWrite(st);
|
|
1282
1511
|
logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
|
|
1283
1512
|
try {
|
|
1284
1513
|
r.ws?.close(4000, "frozen");
|
|
@@ -1327,14 +1556,16 @@ export function createEngine(deps) {
|
|
|
1327
1556
|
return;
|
|
1328
1557
|
}
|
|
1329
1558
|
// share swept / removed / revoked: start fresh, never merge into a
|
|
1330
|
-
// bad base (mismatch protocol). Content REPLAYS to the new share
|
|
1331
|
-
// pending is never dropped on a reset
|
|
1332
|
-
// husk
|
|
1559
|
+
// bad base (mismatch protocol). Content REPLAYS to the new share,
|
|
1560
|
+
// and pending is never dropped on a reset: dropping it leaves the
|
|
1561
|
+
// recreate as a husk with the title and zero messages.
|
|
1333
1562
|
if (/limit|too many|rate/i.test(err)) {
|
|
1334
1563
|
// quota/429 ("max shares reached", "daily create limit", the
|
|
1335
1564
|
// anonymous caps): an immediate re-init on the same socket is a
|
|
1336
|
-
// hot loop against the server
|
|
1565
|
+
// hot loop against the server - back off through the normal
|
|
1337
1566
|
// reconnect cadence instead
|
|
1567
|
+
st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
|
|
1568
|
+
scheduleBindingWrite(st);
|
|
1338
1569
|
logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
|
|
1339
1570
|
r.connecting = false;
|
|
1340
1571
|
try {
|
|
@@ -1346,23 +1577,25 @@ export function createEngine(deps) {
|
|
|
1346
1577
|
return;
|
|
1347
1578
|
}
|
|
1348
1579
|
logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
|
|
1580
|
+
st.lastError = `init refused: ${frame.error ?? "unknown"}`;
|
|
1581
|
+
scheduleBindingWrite(st);
|
|
1349
1582
|
deleteAbandoned(r);
|
|
1350
1583
|
r.shareId = null;
|
|
1351
1584
|
r.secret = null;
|
|
1352
1585
|
r.ackedRev = 0;
|
|
1353
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1586
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1354
1587
|
return;
|
|
1355
1588
|
}
|
|
1356
1589
|
if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
|
|
1357
|
-
// worker lost data behind our back (ATTACH only
|
|
1358
|
-
// legitimately starts at rev 0
|
|
1359
|
-
//
|
|
1590
|
+
// worker lost data behind our back (ATTACH only: a create ack
|
|
1591
|
+
// legitimately starts at rev 0, and misreading that as data loss
|
|
1592
|
+
// spawns a fresh husk)
|
|
1360
1593
|
logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
|
|
1361
1594
|
deleteAbandoned(r);
|
|
1362
1595
|
r.shareId = null;
|
|
1363
1596
|
r.secret = null;
|
|
1364
1597
|
r.ackedRev = 0;
|
|
1365
|
-
wsSend(r, { op: "init", create: { sessionID: st.sid,
|
|
1598
|
+
wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
|
|
1366
1599
|
return;
|
|
1367
1600
|
}
|
|
1368
1601
|
const wasFanoutCreate = !r.initWasAttach && st.everConnected;
|
|
@@ -1384,11 +1617,10 @@ export function createEngine(deps) {
|
|
|
1384
1617
|
clearTimeout(r.initTimer);
|
|
1385
1618
|
r.initTimer = null;
|
|
1386
1619
|
}
|
|
1387
|
-
// backoff reset is DEFERRED until the socket proves stable: a
|
|
1388
|
-
//
|
|
1389
|
-
//
|
|
1390
|
-
//
|
|
1391
|
-
// still ready, no reconnect pending) or a second acked sync.
|
|
1620
|
+
// backoff reset is DEFERRED until the socket proves stable: a server
|
|
1621
|
+
// that accepts init then immediately drops would otherwise reconnect
|
|
1622
|
+
// once per second per session-server pair. Stability means 30s alive
|
|
1623
|
+
// (socket still ready, no reconnect pending) or a second acked sync.
|
|
1392
1624
|
if (r.backoffResetTimer)
|
|
1393
1625
|
clearTimeout(r.backoffResetTimer);
|
|
1394
1626
|
r.backoffResetTimer = setTimeout(() => {
|
|
@@ -1401,6 +1633,56 @@ export function createEngine(deps) {
|
|
|
1401
1633
|
applyPolicy(frame.policy, r.url);
|
|
1402
1634
|
r.intentionalClose = false;
|
|
1403
1635
|
touch(st);
|
|
1636
|
+
// the ack carries the RESOLVED ttl: surface a server/token clamp to
|
|
1637
|
+
// the binding once, and a successful handshake clears any stale
|
|
1638
|
+
// refusal reason the TUI may be showing
|
|
1639
|
+
const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined);
|
|
1640
|
+
if (typeof ackTtl === "number") {
|
|
1641
|
+
const wanted = ttlFor(st);
|
|
1642
|
+
const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null;
|
|
1643
|
+
if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
|
|
1644
|
+
st.ttlClamped = clamp;
|
|
1645
|
+
scheduleBindingWrite(st);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
if (st.lastError) {
|
|
1649
|
+
st.lastError = null;
|
|
1650
|
+
scheduleBindingWrite(st);
|
|
1651
|
+
}
|
|
1652
|
+
// ack-level gate assurance (B2): we asked for a gate (both fields
|
|
1653
|
+
// set) but the server reports none - the page is actually public.
|
|
1654
|
+
// Once per remote; the next init-ok re-checks after a fix.
|
|
1655
|
+
const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== "";
|
|
1656
|
+
if (wantedGate && frame.auth === false && !r.warnedNoGate) {
|
|
1657
|
+
r.warnedNoGate = true;
|
|
1658
|
+
logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`);
|
|
1659
|
+
}
|
|
1660
|
+
else if (frame.auth === true) {
|
|
1661
|
+
r.warnedNoGate = false;
|
|
1662
|
+
}
|
|
1663
|
+
// Wave 3 pmeta: machine-local facts (directory/worktree) ride their
|
|
1664
|
+
// OWN tiny sync once per init-ok, only to remotes we hold a
|
|
1665
|
+
// credential for - anonymous (ownerless) shares never store them.
|
|
1666
|
+
// Server-side they land in the private block, readable with share
|
|
1667
|
+
// auth. Idempotent upsert; rides no shared frame so fan-out
|
|
1668
|
+
// remotes with mixed identity each get exactly their due.
|
|
1669
|
+
if (credOf(r.url).credential) {
|
|
1670
|
+
const priv = {};
|
|
1671
|
+
const dir = (deps.directory ?? "");
|
|
1672
|
+
if (dir)
|
|
1673
|
+
priv.directory = dir;
|
|
1674
|
+
if (deps.worktree)
|
|
1675
|
+
priv.worktree = String(deps.worktree);
|
|
1676
|
+
// the sharer's home lets the server mask absolute paths as ~/...
|
|
1677
|
+
try {
|
|
1678
|
+
priv.home = homedir();
|
|
1679
|
+
}
|
|
1680
|
+
catch { }
|
|
1681
|
+
if (Object.keys(priv).length) {
|
|
1682
|
+
const seq = ++nextSeq;
|
|
1683
|
+
wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq });
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1404
1686
|
announceUrl(st, r);
|
|
1405
1687
|
// first binding write is immediate (the TUI waits on the URL in
|
|
1406
1688
|
// session metadata); ack-driven rev updates stay debounced
|
|
@@ -1420,12 +1702,12 @@ export function createEngine(deps) {
|
|
|
1420
1702
|
}
|
|
1421
1703
|
if (wasFanoutCreate) {
|
|
1422
1704
|
// a server joined an ALREADY-live session (fan-out add, or a
|
|
1423
|
-
// post-mismatch recreate): its fresh share needs the full history
|
|
1424
|
-
// backfill is keyed-idempotent
|
|
1425
|
-
// The enqueue dedupe must step aside: those hashes
|
|
1426
|
-
// entered the machinery", but the NEW remote
|
|
1427
|
-
// of it
|
|
1428
|
-
//
|
|
1705
|
+
// post-mismatch recreate): its fresh share needs the full history,
|
|
1706
|
+
// and backfill is keyed-idempotent while other servers simply
|
|
1707
|
+
// re-upsert. The enqueue dedupe must step aside here: those hashes
|
|
1708
|
+
// say "already entered the machinery", but the NEW remote received
|
|
1709
|
+
// none of it, so a deduped join backfills nothing and the joined
|
|
1710
|
+
// share stays empty.
|
|
1429
1711
|
st.sentHash.clear();
|
|
1430
1712
|
chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
|
|
1431
1713
|
}
|
|
@@ -1533,8 +1815,8 @@ export function createEngine(deps) {
|
|
|
1533
1815
|
return { items, status: st.status };
|
|
1534
1816
|
};
|
|
1535
1817
|
// returns a promise when the frame goes out via HTTP (the dispose path
|
|
1536
|
-
// awaits it
|
|
1537
|
-
// final status never lands,
|
|
1818
|
+
// awaits it: an unawaited fetch dies with the exiting process and the
|
|
1819
|
+
// final status never lands, leaving the share stuck on "live")
|
|
1538
1820
|
const flush = (st) => {
|
|
1539
1821
|
if (st.closed)
|
|
1540
1822
|
return;
|
|
@@ -1544,8 +1826,8 @@ export function createEngine(deps) {
|
|
|
1544
1826
|
// fan out to every remote; each carries its own pending copy and acks
|
|
1545
1827
|
// independently. Remotes mid-handshake or auth-blocked just buffer.
|
|
1546
1828
|
// PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
|
|
1547
|
-
// chunk-frames so a partial
|
|
1548
|
-
//
|
|
1829
|
+
// chunk-frames, so a partial ack can never erase unsent chunks (a
|
|
1830
|
+
// chunk-1 ack followed by socket death used to drop chunks 2..N).
|
|
1549
1831
|
const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
|
|
1550
1832
|
let httpWork;
|
|
1551
1833
|
for (const r of st.remotes.values()) {
|
|
@@ -1571,8 +1853,8 @@ export function createEngine(deps) {
|
|
|
1571
1853
|
}
|
|
1572
1854
|
// never send syncs before the init ack: the worker's per-socket
|
|
1573
1855
|
// ControlDO has no attach until OUR init completes, and a racing
|
|
1574
|
-
// sync dies as "unknown op"
|
|
1575
|
-
//
|
|
1856
|
+
// sync dies as "unknown op", losing whole turns on post-drop
|
|
1857
|
+
// reconnects. The init-ok handler flushes pending itself.
|
|
1576
1858
|
if (r.ws && !r.wsReady)
|
|
1577
1859
|
continue;
|
|
1578
1860
|
if (r.wsReady) {
|
|
@@ -1612,13 +1894,12 @@ export function createEngine(deps) {
|
|
|
1612
1894
|
const flushViaHttp = async (st, r) => {
|
|
1613
1895
|
if (!r.shareId || !r.pending.length)
|
|
1614
1896
|
return;
|
|
1615
|
-
// ALL pending frames, merged (keyed upserts
|
|
1616
|
-
//
|
|
1617
|
-
//
|
|
1618
|
-
//
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
// server row-write + a rebroadcast frame.
|
|
1897
|
+
// ALL pending frames, merged (keyed upserts, later wins). Sending only
|
|
1898
|
+
// the last frame dropped every earlier batch's items whenever the HTTP
|
|
1899
|
+
// fallback carried more than one, losing content on reconnect paths.
|
|
1900
|
+
// DEDUPE BY KEY before chunking: a streaming message appearing in k
|
|
1901
|
+
// pending frames shipped k copies (up to 25x on a replay burst), and
|
|
1902
|
+
// every duplicate cost a server row-write plus a rebroadcast frame.
|
|
1622
1903
|
const byKey = new Map();
|
|
1623
1904
|
const unkeyed = [];
|
|
1624
1905
|
let status;
|
|
@@ -1690,16 +1971,24 @@ export function createEngine(deps) {
|
|
|
1690
1971
|
.catch((e) => logError("flush error", e?.message || e));
|
|
1691
1972
|
}, wait);
|
|
1692
1973
|
};
|
|
1974
|
+
const homeDir = (() => { try {
|
|
1975
|
+
return homedir();
|
|
1976
|
+
}
|
|
1977
|
+
catch {
|
|
1978
|
+
return "";
|
|
1979
|
+
} })();
|
|
1980
|
+
const userPatterns = cfg.redact?.patterns;
|
|
1981
|
+
const redactionRules = activeRules(userPatterns);
|
|
1693
1982
|
const enqueue = (st, entry) => {
|
|
1694
1983
|
if (!st || !entry)
|
|
1695
1984
|
return;
|
|
1696
1985
|
// content dedupe at the machinery boundary: an identical re-feed has
|
|
1697
|
-
// either entered the queue already or been flushed into every
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
//
|
|
1702
|
-
// dormant and
|
|
1986
|
+
// either entered the queue already or been flushed into every remote's
|
|
1987
|
+
// pending/spool, and the per-remote delivery machinery owns it from
|
|
1988
|
+
// there. Re-sending it bumps rev, rewrites storage and re-folds every
|
|
1989
|
+
// viewer for nothing. This is the engine-side home dedupe belongs in:
|
|
1990
|
+
// an adapter-side cache can be poisoned while the session is still
|
|
1991
|
+
// dormant, and then the flag-time content is skipped.
|
|
1703
1992
|
let h;
|
|
1704
1993
|
try {
|
|
1705
1994
|
h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
|
|
@@ -1714,6 +2003,26 @@ export function createEngine(deps) {
|
|
|
1714
2003
|
}
|
|
1715
2004
|
if (h)
|
|
1716
2005
|
st.sentHash.set(entry.key, h);
|
|
2006
|
+
// Wave 3 trust pass: redaction on UNGATED shares only (a gated share
|
|
2007
|
+
// is private from casual observers already; the server owner sees
|
|
2008
|
+
// content either way until encryption lands). The light path
|
|
2009
|
+
// transform applies regardless. Zero-copy when nothing matches.
|
|
2010
|
+
{
|
|
2011
|
+
const gate = authFrame(st);
|
|
2012
|
+
const gated = gate.authLogin !== "" && gate.authPassword !== "";
|
|
2013
|
+
const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated });
|
|
2014
|
+
if (pass.item !== entry.item)
|
|
2015
|
+
entry = { ...entry, item: pass.item };
|
|
2016
|
+
if (pass.hits.length) {
|
|
2017
|
+
const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] };
|
|
2018
|
+
led.count += pass.hits.length;
|
|
2019
|
+
for (const hit of pass.hits) {
|
|
2020
|
+
if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind))
|
|
2021
|
+
led.samples.push(hit);
|
|
2022
|
+
}
|
|
2023
|
+
redactionLedger.set(st.sid, led);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
1717
2026
|
st.queue.set(entry.key, entry.item);
|
|
1718
2027
|
indexMessage(entry);
|
|
1719
2028
|
if (debugEvents)
|
|
@@ -1722,12 +2031,30 @@ export function createEngine(deps) {
|
|
|
1722
2031
|
ensureConnected(st);
|
|
1723
2032
|
scheduleFlush(st);
|
|
1724
2033
|
};
|
|
2034
|
+
// Wave 3 preflight counts: the module-level accessor delegates here
|
|
2035
|
+
statsProvider = (sid) => {
|
|
2036
|
+
const st = sessions.get(sid);
|
|
2037
|
+
let messages = 0;
|
|
2038
|
+
let toolCalls = 0;
|
|
2039
|
+
if (st) {
|
|
2040
|
+
for (const [, merged] of st.merged) {
|
|
2041
|
+
messages++;
|
|
2042
|
+
const content = merged.content;
|
|
2043
|
+
if (Array.isArray(content))
|
|
2044
|
+
toolCalls += content.filter((c) => !!c && c.type === "tool").length;
|
|
2045
|
+
}
|
|
2046
|
+
for (const it of st.queue.values()) {
|
|
2047
|
+
if (it?.type === "message")
|
|
2048
|
+
messages++;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
return { messages, toolCalls };
|
|
2052
|
+
};
|
|
1725
2053
|
const msgIndex = new Map(); // `${sid}:${messageID}`
|
|
1726
2054
|
/** message ids tombstoned this run: stampsPass must never re-upsert an
|
|
1727
|
-
* undone message
|
|
1728
|
-
*
|
|
1729
|
-
*
|
|
1730
|
-
* deleted turn server-side). */
|
|
2055
|
+
* undone message. dropMergedMessage removes the merged entry but msgIndex
|
|
2056
|
+
* keeps the corpse, so a neighboring-segment change recomputes its
|
|
2057
|
+
* stamps and reEnqueueMessage resurrects the deleted turn server-side. */
|
|
1731
2058
|
const tombstoned = new Set();
|
|
1732
2059
|
const indexMessage = (entry) => {
|
|
1733
2060
|
if (entry.item.type !== "message")
|
|
@@ -1791,11 +2118,11 @@ export function createEngine(deps) {
|
|
|
1791
2118
|
const agent = segmentAgent(list, users[k]);
|
|
1792
2119
|
const d = list[users[k]].data;
|
|
1793
2120
|
const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
|
|
1794
|
-
// only touch metadata when the stamp actually CHANGES
|
|
1795
|
-
// `d.metadata ??= {}`
|
|
2121
|
+
// only touch metadata when the stamp actually CHANGES: a blanket
|
|
2122
|
+
// `d.metadata ??= {}` pollutes every user message (including the
|
|
1796
2123
|
// host's live objects on v2 pass-through) with an empty metadata
|
|
1797
|
-
// object, which re-
|
|
1798
|
-
// per feed
|
|
2124
|
+
// object, which re-emits the message once per refresh and churns
|
|
2125
|
+
// rev per feed.
|
|
1799
2126
|
if (want !== d.metadata?.agentSwitch) {
|
|
1800
2127
|
if (want) {
|
|
1801
2128
|
const meta = (d.metadata ??= {});
|
|
@@ -1869,8 +2196,8 @@ export function createEngine(deps) {
|
|
|
1869
2196
|
}
|
|
1870
2197
|
};
|
|
1871
2198
|
/** file tools get metadata.language (shiki lang id, from the path the
|
|
1872
|
-
* call touched)
|
|
1873
|
-
*
|
|
2199
|
+
* call touched). The viewer highlights from this stamp and never
|
|
2200
|
+
* guesses; its extension map only covers old shares without one. */
|
|
1874
2201
|
const LANG_BY_EXT = {
|
|
1875
2202
|
ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
|
|
1876
2203
|
js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
|
|
@@ -1899,6 +2226,10 @@ export function createEngine(deps) {
|
|
|
1899
2226
|
const meta = state?.metadata;
|
|
1900
2227
|
if (!meta)
|
|
1901
2228
|
continue;
|
|
2229
|
+
// paths stay absolute here: the server rewrites them against the
|
|
2230
|
+
// session's working folder on read (wireItems), so old shares get
|
|
2231
|
+
// the same treatment and the host's message objects are never
|
|
2232
|
+
// mutated
|
|
1902
2233
|
const raw = state.input?.path ?? state.input?.filePath;
|
|
1903
2234
|
// language stamp (shiki id from the path)
|
|
1904
2235
|
if (typeof meta.language !== "string" && typeof raw === "string") {
|
|
@@ -2063,9 +2394,9 @@ export function createEngine(deps) {
|
|
|
2063
2394
|
r.idleDropTimer = null;
|
|
2064
2395
|
}
|
|
2065
2396
|
// wsReady delete only when THIS remote still owns a share: a stray
|
|
2066
|
-
// child teardown clears shareId after adopting into the parent
|
|
2067
|
-
// the ControlDO's server-side attach would
|
|
2068
|
-
//
|
|
2397
|
+
// child teardown clears shareId after adopting into the parent, and
|
|
2398
|
+
// the ControlDO's server-side attach would then delete the very
|
|
2399
|
+
// share the parent just inherited.
|
|
2069
2400
|
if (r.wsReady && r.shareId) {
|
|
2070
2401
|
wsSend(r, { op: "delete" });
|
|
2071
2402
|
// backstop: the WS delete is fire-and-forget on the wire — a
|
|
@@ -2185,9 +2516,9 @@ export function createEngine(deps) {
|
|
|
2185
2516
|
const lastInfo = (last?.info ?? last);
|
|
2186
2517
|
const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
|
|
2187
2518
|
// `before` is an OPAQUE cursor: base64url({id, time}) of the page
|
|
2188
|
-
// tail (message-v2.ts codec).
|
|
2189
|
-
//
|
|
2190
|
-
// 100-message page
|
|
2519
|
+
// tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
|
|
2520
|
+
// bare message id, which truncates a v1 backfill to its first
|
|
2521
|
+
// 100-message page.
|
|
2191
2522
|
const lastTime = lastInfo?.time?.created;
|
|
2192
2523
|
if (!lastID || typeof lastTime !== "number")
|
|
2193
2524
|
break;
|
|
@@ -2206,7 +2537,7 @@ export function createEngine(deps) {
|
|
|
2206
2537
|
const backfillSession = async (st, client, sid, info) => {
|
|
2207
2538
|
const sessionInfoValue = info ?? (await sessionInfo(client, sid));
|
|
2208
2539
|
if (sessionInfoValue)
|
|
2209
|
-
enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
|
|
2540
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid));
|
|
2210
2541
|
let modelSeen = false;
|
|
2211
2542
|
const consume = (messages) => {
|
|
2212
2543
|
for (const message of messages) {
|
|
@@ -2251,7 +2582,7 @@ export function createEngine(deps) {
|
|
|
2251
2582
|
// one thing a viewer always needs — fetch it explicitly
|
|
2252
2583
|
const fetched = await sessionInfo(client, st.sid);
|
|
2253
2584
|
if (fetched)
|
|
2254
|
-
enqueue(st, itemFor("session", redact(fetched), st.sid));
|
|
2585
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid));
|
|
2255
2586
|
}
|
|
2256
2587
|
// children (subagent sessions) share the parent's page
|
|
2257
2588
|
if (typeof client.session.list === "function") {
|
|
@@ -2343,12 +2674,8 @@ export function createEngine(deps) {
|
|
|
2343
2674
|
// activates anything on its own
|
|
2344
2675
|
const st = sessions.get(parent);
|
|
2345
2676
|
if (st?.activated && !st.closed) {
|
|
2346
|
-
if (!st.prefsSent) {
|
|
2347
|
-
st.prefsSent = true;
|
|
2348
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2349
|
-
}
|
|
2350
2677
|
if (info)
|
|
2351
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2678
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2352
2679
|
}
|
|
2353
2680
|
return;
|
|
2354
2681
|
}
|
|
@@ -2358,23 +2685,14 @@ export function createEngine(deps) {
|
|
|
2358
2685
|
if (st?.activated && !st.closed) {
|
|
2359
2686
|
// already sharing: opts updates apply, flag removal handled below
|
|
2360
2687
|
if (flag.opts)
|
|
2361
|
-
st
|
|
2362
|
-
if (!st.prefsSent) {
|
|
2363
|
-
st.prefsSent = true;
|
|
2364
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2365
|
-
}
|
|
2688
|
+
applyOpts(st, flag.opts);
|
|
2366
2689
|
if (info)
|
|
2367
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2690
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2368
2691
|
if (t === "session.updated")
|
|
2369
2692
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
|
|
2370
2693
|
return;
|
|
2371
2694
|
}
|
|
2372
2695
|
activate(sid, flag.opts, info);
|
|
2373
|
-
const fresh = sessions.get(sid);
|
|
2374
|
-
if (fresh && !fresh.prefsSent) {
|
|
2375
|
-
fresh.prefsSent = true;
|
|
2376
|
-
enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2377
|
-
}
|
|
2378
2696
|
return;
|
|
2379
2697
|
}
|
|
2380
2698
|
if (st?.activated && !st.closed && t === "session.updated") {
|
|
@@ -2431,12 +2749,8 @@ export function createEngine(deps) {
|
|
|
2431
2749
|
}
|
|
2432
2750
|
if (!st)
|
|
2433
2751
|
return;
|
|
2434
|
-
if (!st.prefsSent) {
|
|
2435
|
-
st.prefsSent = true;
|
|
2436
|
-
enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
|
|
2437
|
-
}
|
|
2438
2752
|
if (info)
|
|
2439
|
-
enqueue(st, itemFor("session", redact(info), sid));
|
|
2753
|
+
enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
|
|
2440
2754
|
if (t === "session.updated" && st.activated && !st.closed) {
|
|
2441
2755
|
chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
|
|
2442
2756
|
}
|
|
@@ -2466,12 +2780,12 @@ export function createEngine(deps) {
|
|
|
2466
2780
|
touch(st);
|
|
2467
2781
|
if (type === "busy") {
|
|
2468
2782
|
// work resumed: a pending completed flip is cancelled (opencode
|
|
2469
|
-
// idles between sequential automation prompts
|
|
2470
|
-
// "done"), a recovered retry is forgiven, share is live
|
|
2471
|
-
// The busy epoch is the completed-flip guard: only WORK
|
|
2472
|
-
// invalidates a pending settle
|
|
2473
|
-
//
|
|
2474
|
-
//
|
|
2783
|
+
// idles between sequential automation prompts, which is NOT
|
|
2784
|
+
// "done"), a recovered retry is forgiven, and the share is live
|
|
2785
|
+
// again. The busy epoch is the completed-flip guard: only WORK
|
|
2786
|
+
// invalidates a pending settle. v1.18 republishes the status/idle
|
|
2787
|
+
// pair on failure paths, and a repeated idle must NOT void it or
|
|
2788
|
+
// the share stays on live forever.
|
|
2475
2789
|
st.statusEpoch++;
|
|
2476
2790
|
if (st.completedTimer) {
|
|
2477
2791
|
clearTimeout(st.completedTimer);
|
|
@@ -2485,8 +2799,8 @@ export function createEngine(deps) {
|
|
|
2485
2799
|
else if (type === "idle") {
|
|
2486
2800
|
// opencode's idle is authoritative turn end (v1 holds busy
|
|
2487
2801
|
// through steps, tool runs and permission waits). CONTENT
|
|
2488
|
-
// flushes now
|
|
2489
|
-
// interval (
|
|
2802
|
+
// flushes now, because turn-end batches must not ride the sync
|
|
2803
|
+
// interval (subagent finals lagged one interval otherwise).
|
|
2490
2804
|
// Only the completed flip waits out completedDelayMs, guarded
|
|
2491
2805
|
// by the busy epoch: work resuming voids it, repeated idle
|
|
2492
2806
|
// publications do not. Dispose flushes a pending flip, so CI
|
|
@@ -2631,11 +2945,11 @@ export function createEngine(deps) {
|
|
|
2631
2945
|
const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
|
|
2632
2946
|
enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
|
|
2633
2947
|
}
|
|
2634
|
-
// an assistant message that DIED (finish:"error")
|
|
2635
|
-
// retries exhausted
|
|
2636
|
-
// message
|
|
2637
|
-
//
|
|
2638
|
-
//
|
|
2948
|
+
// an assistant message that DIED (finish:"error"), e.g. all
|
|
2949
|
+
// retries exhausted, surfaces as a 200 response with an errored
|
|
2950
|
+
// message rather than session.error. The share must still say
|
|
2951
|
+
// error. Works for v2-native messages too (finish lives on the
|
|
2952
|
+
// message itself).
|
|
2639
2953
|
if (info?.finish === "error") {
|
|
2640
2954
|
st.badSignal = true;
|
|
2641
2955
|
}
|
|
@@ -2701,7 +3015,7 @@ export function createEngine(deps) {
|
|
|
2701
3015
|
if (flushed && typeof flushed.then === "function")
|
|
2702
3016
|
await flushed.catch(() => { });
|
|
2703
3017
|
// dispose with a mid-handshake socket: the deferred pending
|
|
2704
|
-
// would die with the process
|
|
3018
|
+
// would die with the process - land it over HTTP instead
|
|
2705
3019
|
for (const r of st.remotes.values()) {
|
|
2706
3020
|
if (!r.wsReady && r.shareId && r.pending.length) {
|
|
2707
3021
|
await flushViaHttp(st, r).catch(() => { });
|
|
@@ -2722,6 +3036,26 @@ export function createEngine(deps) {
|
|
|
2722
3036
|
st.metaTimer = null;
|
|
2723
3037
|
await writeBinding(st).catch(() => { });
|
|
2724
3038
|
}
|
|
3039
|
+
// URL-at-exit barrier: a very short `opencode run` can exit before
|
|
3040
|
+
// the create handshake finishes, and CI reads the urlFile the
|
|
3041
|
+
// moment the process ends. Bounded 5s wait for every share that is
|
|
3042
|
+
// mid-handshake or not yet announced (a dead worker costs exactly
|
|
3043
|
+
// the cap; a healthy one is instant).
|
|
3044
|
+
const barrierStart = Date.now();
|
|
3045
|
+
while (Date.now() - barrierStart < 5000) {
|
|
3046
|
+
let pendingAnnounce = false;
|
|
3047
|
+
for (const st of sessions.values()) {
|
|
3048
|
+
for (const r of st.remotes.values()) {
|
|
3049
|
+
if (r.authBlocked || r.collided)
|
|
3050
|
+
continue;
|
|
3051
|
+
if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting)))
|
|
3052
|
+
pendingAnnounce = true;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
if (!pendingAnnounce)
|
|
3056
|
+
break;
|
|
3057
|
+
await new Promise((res) => setTimeout(res, 100));
|
|
3058
|
+
}
|
|
2725
3059
|
for (const st of sessions.values()) {
|
|
2726
3060
|
for (const r of st.remotes.values()) {
|
|
2727
3061
|
try {
|