@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/ci/openshare.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // > code defaults. Keys: servers (multi-server: url -> {token, code,
20
20
  // label, active, adminToken}), workerUrl, createToken (legacy pair),
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
- // completedDelayMs, modelLabel, ttlDays, openTasksInNewTab,
22
+ // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
24
  // announceStderr.
25
25
  //
@@ -35,12 +35,13 @@ var DEFAULTS = {
35
35
  debounceMs: 1e4,
36
36
  completedDelayMs: 5000,
37
37
  modelLabel: "$Provider/$Model",
38
- ttlDays: 90,
39
- openTasksInNewTab: true,
38
+ ttlHours: 90 * 24,
40
39
  authLogin: "",
41
40
  authPassword: "",
42
41
  spoolDir: "",
43
42
  urlFile: "",
43
+ urlFileFormat: "url",
44
+ configSet: "",
44
45
  announceStderr: true
45
46
  };
46
47
  var CONFIG_NAME = "openshare.config.json";
@@ -79,6 +80,76 @@ function configPaths(pluginDir, env = process.env) {
79
80
  function configHomePath(env = process.env) {
80
81
  return join(globalConfigDir(env), CONFIG_NAME);
81
82
  }
83
+ function configSourceMap(pluginDir, env = process.env) {
84
+ const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
85
+ const known = new Set([...Object.keys(DEFAULTS), "shareMode", "servers"]);
86
+ const sources = {};
87
+ const envKeys = {
88
+ workerUrl: env.OPENSHARE_WORKER_URL,
89
+ createToken: env.OPENSHARE_CREATE_TOKEN,
90
+ debounceMs: env.OPENSHARE_DEBOUNCE_MS,
91
+ completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
92
+ modelLabel: env.OPENSHARE_MODEL_LABEL,
93
+ ttlHours: env.OPENSHARE_TTL_HOURS,
94
+ ttlDays: env.OPENSHARE_TTL_DAYS,
95
+ authLogin: env.OPENSHARE_AUTH_LOGIN,
96
+ authPassword: env.OPENSHARE_AUTH_PASSWORD,
97
+ urlFile: env.OPENSHARE_URL_FILE,
98
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
99
+ configSet: env.OPENSHARE_CONFIG_SET,
100
+ spoolDir: env.OPENSHARE_SPOOL_DIR,
101
+ announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
102
+ shareMode: env.OPENSHARE_SHARE_MODE
103
+ };
104
+ for (const [key, value] of Object.entries(envKeys)) {
105
+ if (value !== undefined && value !== "")
106
+ sources[key] = "environment";
107
+ }
108
+ if (sources.ttlDays && !sources.ttlHours)
109
+ sources.ttlHours = sources.ttlDays;
110
+ delete sources.ttlDays;
111
+ if (env.OPENSHARE_CONFIG_JSON) {
112
+ try {
113
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON);
114
+ if (raw && typeof raw === "object") {
115
+ for (const [key, value] of Object.entries(raw)) {
116
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
117
+ continue;
118
+ if (sources[key] === "environment")
119
+ continue;
120
+ if (key === "servers" || known.has(key))
121
+ sources[key] = "OPENSHARE_CONFIG_JSON";
122
+ }
123
+ }
124
+ } catch {}
125
+ }
126
+ for (const path of configPaths(pluginDir, env)) {
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(readFileSync(path, "utf8"));
130
+ if (!raw || typeof raw !== "object")
131
+ continue;
132
+ } catch {
133
+ continue;
134
+ }
135
+ const label = basename(path) === LOCAL_NAME ? `openshare.local.json (${dirname(path).split(/[\\/]/).pop()})` : `${basename(path)} (${dirname(path).split(/[\\/]/).pop()})`;
136
+ for (const [key, value] of Object.entries(raw)) {
137
+ if (key === "servers") {
138
+ if (sources.servers === undefined && normalizeServers(value))
139
+ sources.servers = label;
140
+ continue;
141
+ }
142
+ if (sources[key] !== undefined || !known.has(key))
143
+ continue;
144
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
145
+ continue;
146
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
147
+ continue;
148
+ sources[key] = label;
149
+ }
150
+ }
151
+ return sources;
152
+ }
82
153
  function num(value, fallback) {
83
154
  const parsed = Number(value);
84
155
  return Number.isFinite(parsed) ? parsed : fallback;
@@ -105,6 +176,8 @@ function normalizeServers(raw) {
105
176
  entry.adminToken = e.adminToken;
106
177
  if (e.active === true)
107
178
  entry.active = true;
179
+ if (e.anonymous === true)
180
+ entry.anonymous = true;
108
181
  out[url] = entry;
109
182
  }
110
183
  return Object.keys(out).length ? out : undefined;
@@ -156,27 +229,57 @@ function loadConfig(pluginDir, options) {
156
229
  const cfg = { ...DEFAULTS };
157
230
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
158
231
  const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
159
- for (const path of [...configPaths(pluginDir)].reverse()) {
160
- try {
161
- const raw = JSON.parse(readFileSync(path, "utf8"));
162
- if (!raw || typeof raw !== "object")
232
+ const applyObject = (raw) => {
233
+ if (!raw || typeof raw !== "object")
234
+ return;
235
+ for (const [key, value] of Object.entries(raw)) {
236
+ if (key === "servers") {
237
+ const servers = normalizeServers(value);
238
+ if (servers)
239
+ cfg.servers = mergeServerGroups(cfg.servers, servers);
163
240
  continue;
164
- for (const [key, value] of Object.entries(raw)) {
165
- if (key === "servers") {
166
- const servers = normalizeServers(value);
167
- if (servers)
168
- cfg.servers = mergeServerGroups(cfg.servers, servers);
169
- continue;
241
+ }
242
+ if (key === "redact") {
243
+ if (value && typeof value === "object" && Array.isArray(value.patterns)) {
244
+ const pats = value.patterns.filter((x) => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50);
245
+ cfg.redact = { patterns: pats };
170
246
  }
171
- if (!known.has(key))
172
- continue;
173
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
174
- continue;
175
- if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
176
- continue;
177
- cfg[key] = value;
247
+ continue;
178
248
  }
179
- } catch {}
249
+ if (key === "meta") {
250
+ if (value && typeof value === "object" && !Array.isArray(value)) {
251
+ const clean = {};
252
+ for (const [mk, mv] of Object.entries(value)) {
253
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64)
254
+ clean[mk] = mv;
255
+ }
256
+ if (Object.keys(clean).length)
257
+ cfg.meta = { ...cfg.meta, ...clean };
258
+ }
259
+ continue;
260
+ }
261
+ if (key === "ttlDays") {
262
+ const d = Math.floor(Number(value));
263
+ if (Number.isFinite(d) && d > 0)
264
+ cfg.ttlHours = d * 24;
265
+ continue;
266
+ }
267
+ if (!known.has(key))
268
+ continue;
269
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
270
+ continue;
271
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
272
+ continue;
273
+ cfg[key] = value;
274
+ }
275
+ };
276
+ for (const path of [...configPaths(pluginDir)].reverse()) {
277
+ try {
278
+ applyObject(JSON.parse(readFileSync(path, "utf8")));
279
+ } catch (e) {
280
+ if (existsSync(path))
281
+ console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
282
+ }
180
283
  }
181
284
  if (options && typeof options === "object") {
182
285
  const serversOpt = normalizeServers(options.servers);
@@ -195,6 +298,13 @@ function loadConfig(pluginDir, options) {
195
298
  cfg.shareMode = mode;
196
299
  }
197
300
  const env = process.env;
301
+ if (env.OPENSHARE_CONFIG_JSON) {
302
+ try {
303
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON));
304
+ } catch (e) {
305
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", e?.message ?? e);
306
+ }
307
+ }
198
308
  if (env.OPENSHARE_WORKER_URL)
199
309
  cfg.workerUrl = env.OPENSHARE_WORKER_URL;
200
310
  if (env.OPENSHARE_CREATE_TOKEN)
@@ -205,14 +315,20 @@ function loadConfig(pluginDir, options) {
205
315
  cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
206
316
  if (env.OPENSHARE_MODEL_LABEL !== undefined)
207
317
  cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
318
+ if (env.OPENSHARE_TTL_HOURS)
319
+ cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
208
320
  if (env.OPENSHARE_TTL_DAYS)
209
- cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
321
+ cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
210
322
  if (env.OPENSHARE_AUTH_LOGIN !== undefined)
211
323
  cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
212
324
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
213
325
  cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
214
326
  if (env.OPENSHARE_URL_FILE !== undefined)
215
327
  cfg.urlFile = env.OPENSHARE_URL_FILE;
328
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url")
329
+ cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT;
330
+ if (env.OPENSHARE_CONFIG_SET)
331
+ cfg.configSet = env.OPENSHARE_CONFIG_SET;
216
332
  if (env.OPENSHARE_SPOOL_DIR)
217
333
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
218
334
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
@@ -221,6 +337,14 @@ function loadConfig(pluginDir, options) {
221
337
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
222
338
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
223
339
  cfg.urlFile = URL_FILE_NONE;
340
+ if (cfg.workerUrl)
341
+ cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
342
+ if (cfg.servers) {
343
+ const fixed = {};
344
+ for (const [u, e] of Object.entries(cfg.servers))
345
+ fixed[u.trim().replace(/\/+$/, "")] = e;
346
+ cfg.servers = fixed;
347
+ }
224
348
  return cfg;
225
349
  }
226
350
  function spoolDirFor(cfg) {
@@ -345,8 +469,9 @@ function resolveShareMode(cfg, input) {
345
469
 
346
470
  // src/core.ts
347
471
  import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
472
+ import { homedir } from "node:os";
348
473
  import { createHash } from "node:crypto";
349
- import { join as join2 } from "node:path";
474
+ import { isAbsolute, join as join2 } from "node:path";
350
475
 
351
476
  // src/contract.ts
352
477
  function messageTombstone(sessionID, messageID) {
@@ -564,6 +689,109 @@ function queueBindingWrite(sid, fn) {
564
689
  return run;
565
690
  }
566
691
 
692
+ // src/redact.ts
693
+ var BUILTINS = [
694
+ { kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
695
+ { kind: "gh-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/g },
696
+ { kind: "gh-token", re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/g },
697
+ { kind: "api-key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/g },
698
+ { kind: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
699
+ { kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
700
+ { kind: "bearer", re: /\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/gi },
701
+ { kind: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
702
+ { kind: "env-secret", re: /(?<![A-Za-z0-9_])(?:export\s+)?[A-Z0-9_]*(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?)[A-Z0-9_]*\s*=\s*\S+/g },
703
+ { kind: "conn-string", re: /\b[a-z][a-z0-9+.-]*:\/\/[^:@/\s"']+:[^@/\s"']{4,}@/gi }
704
+ ];
705
+ function compile(patterns) {
706
+ if (!Array.isArray(patterns))
707
+ return [];
708
+ const out = [];
709
+ for (const p of patterns) {
710
+ if (typeof p !== "string" || !p)
711
+ continue;
712
+ try {
713
+ out.push({ kind: "user", re: new RegExp(p, "g") });
714
+ } catch {}
715
+ }
716
+ return out;
717
+ }
718
+ function tildePath(p, home) {
719
+ if (!p || !home || p.length <= home.length)
720
+ return p;
721
+ const norm = (s) => s.replace(/[\\/]+/g, "/").replace(/\/$/, "");
722
+ const np = norm(p);
723
+ const nh = norm(home);
724
+ if (np === nh)
725
+ return "~";
726
+ if (np.startsWith(nh + "/"))
727
+ return "~" + np.slice(nh.length);
728
+ return p;
729
+ }
730
+ var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
731
+ function redactItem(item, opts) {
732
+ const flat = JSON.stringify(item) ?? "null";
733
+ const needRedact = opts.enabled && opts.rules.some((r) => {
734
+ r.re.lastIndex = 0;
735
+ return r.re.test(flat);
736
+ });
737
+ const needPaths = !!opts.home && flat.includes(opts.home);
738
+ if (!needRedact && !needPaths)
739
+ return { item, hits: [] };
740
+ const clone = JSON.parse(flat);
741
+ const stats = { hits: [], changed: false };
742
+ walk(clone, opts, stats);
743
+ return { item: clone, hits: stats.hits };
744
+ }
745
+ function walk(node, opts, stats, key) {
746
+ if (Array.isArray(node)) {
747
+ for (let i = 0;i < node.length; i++) {
748
+ const v = node[i];
749
+ if (typeof v === "string")
750
+ node[i] = applyString(v, opts, stats, key);
751
+ else
752
+ walk(v, opts, stats, key);
753
+ }
754
+ return;
755
+ }
756
+ if (node && typeof node === "object") {
757
+ for (const [k, v] of Object.entries(node)) {
758
+ if (typeof v === "string")
759
+ node[k] = applyString(v, opts, stats, k);
760
+ else
761
+ walk(v, opts, stats, k);
762
+ }
763
+ }
764
+ }
765
+ function applyString(v, opts, stats, key) {
766
+ let out = v;
767
+ if (opts.enabled) {
768
+ for (const r of opts.rules) {
769
+ r.re.lastIndex = 0;
770
+ out = out.replace(r.re, (full) => {
771
+ if (stats.hits.length < 8 && !stats.hits.some((h) => h.kind === r.kind)) {
772
+ stats.hits.push({ kind: r.kind, sample: full.slice(0, 60) });
773
+ }
774
+ stats.changed = true;
775
+ if (r.kind === "conn-string") {
776
+ const at = full.lastIndexOf("@");
777
+ const colon = full.indexOf(":", full.indexOf("://") + 3) + 1;
778
+ if (at > colon && colon > 0)
779
+ return full.slice(0, colon) + "[redacted:conn-string]" + full.slice(at);
780
+ }
781
+ return `[redacted:${r.kind}]`;
782
+ });
783
+ }
784
+ }
785
+ if (key && PATH_KEYS.has(key) && opts.home && out.includes(opts.home)) {
786
+ out = tildePath(out, opts.home);
787
+ stats.changed = true;
788
+ }
789
+ return out;
790
+ }
791
+ function activeRules(userPatterns) {
792
+ return [...BUILTINS, ...compile(userPatterns)];
793
+ }
794
+
567
795
  // src/core.ts
568
796
  var DEFAULT_POLICY = {
569
797
  mode: "persistent",
@@ -616,6 +844,19 @@ function redact(info) {
616
844
  delete metadata[METADATA_KEY];
617
845
  return { ...info, metadata };
618
846
  }
847
+ function stripLocalFacts(info) {
848
+ if (!info || typeof info !== "object")
849
+ return info;
850
+ const out = { ...info };
851
+ let changed = false;
852
+ for (const k of ["directory", "worktree"]) {
853
+ if (out[k] !== undefined) {
854
+ delete out[k];
855
+ changed = true;
856
+ }
857
+ }
858
+ return changed ? out : info;
859
+ }
619
860
  function shareFlag(info) {
620
861
  const raw = info?.metadata?.[METADATA_KEY];
621
862
  if (!raw || typeof raw !== "object")
@@ -627,8 +868,10 @@ function shareFlag(info) {
627
868
  const opts = {};
628
869
  if (optsRaw && typeof optsRaw === "object") {
629
870
  const o = optsRaw;
630
- if (typeof o.ttlDays === "number" && o.ttlDays > 0)
631
- opts.ttlDays = o.ttlDays;
871
+ if (typeof o.ttlHours === "number" && o.ttlHours > 0)
872
+ opts.ttlHours = Math.floor(o.ttlHours);
873
+ else if (typeof o.ttlDays === "number" && o.ttlDays > 0)
874
+ opts.ttlHours = Math.floor(o.ttlDays * 24);
632
875
  if (typeof o.authLogin === "string")
633
876
  opts.authLogin = o.authLogin;
634
877
  if (typeof o.authPassword === "string")
@@ -638,6 +881,9 @@ function shareFlag(info) {
638
881
  }
639
882
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
640
883
  }
884
+ var liveTargets = [];
885
+ var statsProvider = null;
886
+ var redactionLedger = new Map;
641
887
  function createEngine(deps) {
642
888
  let cfg = loadConfig(deps.pluginDir, deps.options);
643
889
  const mode = resolveShareMode(cfg, deps);
@@ -669,8 +915,13 @@ function createEngine(deps) {
669
915
  log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
670
916
  const resolveTargets = () => {
671
917
  const merged = {};
672
- if (cfg.workerUrl)
918
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
919
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL;
920
+ if (cfg.workerUrl && (!hasServers || envWorker))
673
921
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
922
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
923
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
924
+ }
674
925
  if (cfg.servers)
675
926
  for (const [u, e] of Object.entries(cfg.servers))
676
927
  merged[u] = { ...merged[u], ...e };
@@ -680,6 +931,7 @@ function createEngine(deps) {
680
931
  return chosen.map((url) => ({ url, entry: merged[url] }));
681
932
  };
682
933
  let targets = resolveTargets();
934
+ liveTargets = targets.map((t) => t.url);
683
935
  const credState = new Map;
684
936
  const credOf = (url) => {
685
937
  let s = credState.get(url);
@@ -704,40 +956,98 @@ function createEngine(deps) {
704
956
  };
705
957
  seedCredentials();
706
958
  const ENROLL_RETRY_MS = 60000;
959
+ const setsFetched = new Set;
960
+ const adoptServerSuggestions = async (url, token) => {
961
+ if (setsFetched.has(url))
962
+ return;
963
+ setsFetched.add(url);
964
+ try {
965
+ const r = await fetch(`${url}/api/token/sets`, {
966
+ headers: { authorization: `Bearer ${token}` },
967
+ signal: AbortSignal.timeout(8000)
968
+ });
969
+ const body = r.ok ? await r.json().catch(() => null) : null;
970
+ const sets = body?.sets ?? {};
971
+ const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0];
972
+ if (!pick)
973
+ return;
974
+ const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel;
975
+ const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined;
976
+ const src = configSourceMap(deps.pluginDir ?? "");
977
+ const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel;
978
+ const modeUnset = cfg.shareMode === undefined && !src.shareMode;
979
+ const adoptModel = wantModel && modelUnset;
980
+ const adoptMode = wantMode && modeUnset;
981
+ if (!adoptModel && !adoptMode)
982
+ return;
983
+ const home = configHomePath();
984
+ if (!home)
985
+ return;
986
+ let doc = {};
987
+ try {
988
+ doc = JSON.parse(readFileSync2(home, "utf8"));
989
+ if (!doc || typeof doc !== "object")
990
+ doc = {};
991
+ } catch {}
992
+ let dirty = false;
993
+ if (adoptModel && doc.modelLabel === undefined) {
994
+ doc.modelLabel = wantModel;
995
+ dirty = true;
996
+ log(`${url}: adopted modelLabel from the server config set`);
997
+ }
998
+ if (adoptMode && doc.shareMode === undefined) {
999
+ doc.shareMode = wantMode;
1000
+ dirty = true;
1001
+ log(`${url}: adopted shareMode from the server config set`);
1002
+ }
1003
+ if (dirty)
1004
+ writeFileSync2(home, JSON.stringify(doc, null, 2) + `
1005
+ `);
1006
+ } catch {}
1007
+ };
707
1008
  const ensureCredential = async (url) => {
708
1009
  const c = credOf(url);
709
1010
  const target = targets.find((t) => t.url === url);
710
- if (c.credential)
1011
+ if (c.credential) {
1012
+ adoptServerSuggestions(url, c.credential);
711
1013
  return c.credential;
1014
+ }
712
1015
  if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
713
1016
  return "";
714
1017
  c.lastEnrollAt = Date.now();
715
- try {
716
- const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
717
- const hb = h.ok ? await h.json().catch(() => null) : null;
718
- const authMode2 = hb?.auth?.mode;
719
- const code = target?.entry.code ?? "";
720
- if (authMode2 === "public" || code) {
721
- const r = await fetch(`${url}/api/enroll`, {
722
- method: "POST",
723
- headers: { "content-type": "application/json" },
724
- body: JSON.stringify({ code }),
725
- signal: AbortSignal.timeout(1e4)
726
- });
727
- const b = r.ok ? await r.json().catch(() => null) : null;
728
- if (b?.ok && typeof b.token === "string") {
729
- c.credential = b.token;
1018
+ const code = target?.entry.code ?? "";
1019
+ if (!code) {
1020
+ try {
1021
+ const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
1022
+ const hb = h.ok ? await h.json().catch(() => null) : null;
1023
+ if (hb?.auth?.mode === "public") {
730
1024
  c.error = "";
731
- const savedTo = saveServerToken(url, c.credential);
732
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
733
- return c.credential;
1025
+ return "";
734
1026
  }
735
- c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
736
- } else if (authMode2 === "private") {
737
- c.error = "private server: add servers.{url}.code (or token) to enroll";
738
- } else {
739
- c.error = `server health unreachable (HTTP ${h.status})`;
1027
+ c.error = "private server - onboard via /openshare (needs an access code)";
1028
+ } catch (e) {
1029
+ c.error = `server unreachable: ${e?.message || e}`;
740
1030
  }
1031
+ logError(`no credential for ${url}: ${c.error}`);
1032
+ return "";
1033
+ }
1034
+ try {
1035
+ const r = await fetch(`${url}/api/enroll`, {
1036
+ method: "POST",
1037
+ headers: { "content-type": "application/json" },
1038
+ body: JSON.stringify({ code }),
1039
+ signal: AbortSignal.timeout(1e4)
1040
+ });
1041
+ const b = r.ok ? await r.json().catch(() => null) : null;
1042
+ if (b?.ok && typeof b.token === "string") {
1043
+ c.credential = b.token;
1044
+ c.error = "";
1045
+ const savedTo = saveServerToken(url, c.credential);
1046
+ log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
1047
+ adoptServerSuggestions(url, c.credential);
1048
+ return c.credential;
1049
+ }
1050
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
741
1051
  } catch (e) {
742
1052
  c.error = `enrollment failed: ${e?.message || e}`;
743
1053
  }
@@ -853,7 +1163,8 @@ function createEngine(deps) {
853
1163
  badSignal: false,
854
1164
  lastSentStatus: null,
855
1165
  closed: false,
856
- prefsSent: false,
1166
+ lastError: null,
1167
+ ttlClamped: null,
857
1168
  binding: null,
858
1169
  bindingQueried: false,
859
1170
  optOuts: new Set,
@@ -872,6 +1183,7 @@ function createEngine(deps) {
872
1183
  spoolRoot = spoolDirFor(cfg);
873
1184
  const before = new Set(targets.map((t) => t.url));
874
1185
  targets = resolveTargets();
1186
+ liveTargets = targets.map((t) => t.url);
875
1187
  seedCredentials();
876
1188
  clampToPolicy();
877
1189
  if (debugEvents) {
@@ -1045,6 +1357,10 @@ function createEngine(deps) {
1045
1357
  const value = { v: 2, servers, enabled: true };
1046
1358
  if (mode === "manual" && st.opts)
1047
1359
  value.opts = st.opts;
1360
+ if (st.lastError)
1361
+ value.lastError = st.lastError;
1362
+ if (st.ttlClamped)
1363
+ value.ttlClamped = st.ttlClamped;
1048
1364
  st.binding = value;
1049
1365
  st.wroteBinding = true;
1050
1366
  await writeMetadata(st, value);
@@ -1225,24 +1541,72 @@ function createEngine(deps) {
1225
1541
  if (cfg.announceStderr)
1226
1542
  log(`live: ${url}`);
1227
1543
  if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
1228
- const file = cfg.urlFile || join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
1544
+ const file = cfg.urlFile ? isAbsolute(cfg.urlFile) ? cfg.urlFile : join2(deps.directory ?? process.cwd(), cfg.urlFile) : join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
1229
1545
  try {
1230
- appendFileSync(file, url + `
1231
- `);
1546
+ const line = cfg.urlFileFormat === "json" ? JSON.stringify({
1547
+ id: r.shareId,
1548
+ url,
1549
+ status: st.lastSentStatus ?? "waiting",
1550
+ gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== ""
1551
+ }) + `
1552
+ ` : url + `
1553
+ `;
1554
+ appendFileSync(file, line);
1232
1555
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1233
1556
  urlFileAnnounced = true;
1234
1557
  lastUrlFile = file;
1235
1558
  log(`url file: ${file}`);
1236
1559
  }
1237
- } catch {}
1560
+ } catch (e) {
1561
+ logError(`url file write failed (${file}):`, e?.message || e);
1562
+ }
1238
1563
  }
1239
1564
  };
1565
+ let halfAuthWarned = false;
1240
1566
  const authFrame = (st) => {
1241
1567
  const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
1242
1568
  const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
1569
+ if (login === "" !== (password === "")) {
1570
+ if (!halfAuthWarned) {
1571
+ halfAuthWarned = true;
1572
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed");
1573
+ }
1574
+ return { authLogin: "", authPassword: "" };
1575
+ }
1243
1576
  return { authLogin: login, authPassword: password };
1244
1577
  };
1245
- const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
1578
+ const ttlFor = (st) => st.opts?.ttlHours ?? cfg.ttlHours;
1579
+ const metaFor = () => {
1580
+ if (!cfg.meta || typeof cfg.meta !== "object")
1581
+ return;
1582
+ const out = {};
1583
+ let n = 0;
1584
+ for (const [k, v] of Object.entries(cfg.meta)) {
1585
+ if (n >= 20)
1586
+ break;
1587
+ out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m);
1588
+ n++;
1589
+ }
1590
+ return Object.keys(out).length ? out : undefined;
1591
+ };
1592
+ const applyOpts = (st, opts) => {
1593
+ if (!opts)
1594
+ return;
1595
+ const prev = st.opts ? JSON.stringify(st.opts) : "";
1596
+ st.opts = opts;
1597
+ if (prev === JSON.stringify(opts))
1598
+ return;
1599
+ for (const [url, r] of st.remotes) {
1600
+ if (!r.shareId || !r.secret || !r.wsReady)
1601
+ continue;
1602
+ const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
1603
+ if (st.opts?.ttlHours)
1604
+ attach.ttlHours = st.opts.ttlHours;
1605
+ const sent = wsSend(r, { op: "init", attach });
1606
+ if (sent)
1607
+ log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1608
+ }
1609
+ };
1246
1610
  const deleteAbandoned = (r) => {
1247
1611
  const id = r.shareId;
1248
1612
  const secret = r.secret;
@@ -1326,6 +1690,25 @@ function createEngine(deps) {
1326
1690
  r.announced = false;
1327
1691
  queueConnect(st, r);
1328
1692
  }
1693
+ let rearmed = false;
1694
+ for (const [url, e] of Object.entries(servers)) {
1695
+ if (st.optOuts.has(url) || st.remotes.has(url))
1696
+ continue;
1697
+ const ee = e;
1698
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
1699
+ continue;
1700
+ const r = remoteFor(st, url);
1701
+ r.shareId = ee.id;
1702
+ r.secret = ee.secret;
1703
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
1704
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
1705
+ queueConnect(st, r);
1706
+ rearmed = true;
1707
+ }
1708
+ if (rearmed) {
1709
+ st.sentHash.clear();
1710
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
1711
+ }
1329
1712
  } catch {}
1330
1713
  };
1331
1714
  const connect = async (st, r) => {
@@ -1417,7 +1800,7 @@ function createEngine(deps) {
1417
1800
  if (r.initWasAttach) {
1418
1801
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
1419
1802
  } else {
1420
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1803
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...cfg.configSet ? { set: cfg.configSet } : {}, ...metaFor() ? { meta: metaFor() } : {} } });
1421
1804
  }
1422
1805
  r.initTimer = setTimeout(() => {
1423
1806
  if (seq !== r.socketSeq)
@@ -1534,6 +1917,8 @@ function createEngine(deps) {
1534
1917
  const err = String(frame.error ?? "");
1535
1918
  if (/frozen/i.test(err)) {
1536
1919
  r.authBlocked = true;
1920
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
1921
+ scheduleBindingWrite(st);
1537
1922
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1538
1923
  try {
1539
1924
  r.ws?.close(4000, "frozen");
@@ -1568,6 +1953,8 @@ function createEngine(deps) {
1568
1953
  return;
1569
1954
  }
1570
1955
  if (/limit|too many|rate/i.test(err)) {
1956
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
1957
+ scheduleBindingWrite(st);
1571
1958
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1572
1959
  r.connecting = false;
1573
1960
  try {
@@ -1578,11 +1965,13 @@ function createEngine(deps) {
1578
1965
  return;
1579
1966
  }
1580
1967
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1968
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`;
1969
+ scheduleBindingWrite(st);
1581
1970
  deleteAbandoned(r);
1582
1971
  r.shareId = null;
1583
1972
  r.secret = null;
1584
1973
  r.ackedRev = 0;
1585
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1974
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...cfg.configSet ? { set: cfg.configSet } : {}, ...metaFor() ? { meta: metaFor() } : {} } });
1586
1975
  return;
1587
1976
  }
1588
1977
  if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
@@ -1591,7 +1980,7 @@ function createEngine(deps) {
1591
1980
  r.shareId = null;
1592
1981
  r.secret = null;
1593
1982
  r.ackedRev = 0;
1594
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1983
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...cfg.configSet ? { set: cfg.configSet } : {}, ...metaFor() ? { meta: metaFor() } : {} } });
1595
1984
  return;
1596
1985
  }
1597
1986
  const wasFanoutCreate = !r.initWasAttach && st.everConnected;
@@ -1623,6 +2012,41 @@ function createEngine(deps) {
1623
2012
  applyPolicy(frame.policy, r.url);
1624
2013
  r.intentionalClose = false;
1625
2014
  touch(st);
2015
+ const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined;
2016
+ if (typeof ackTtl === "number") {
2017
+ const wanted = ttlFor(st);
2018
+ const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null;
2019
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
2020
+ st.ttlClamped = clamp;
2021
+ scheduleBindingWrite(st);
2022
+ }
2023
+ }
2024
+ if (st.lastError) {
2025
+ st.lastError = null;
2026
+ scheduleBindingWrite(st);
2027
+ }
2028
+ const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== "";
2029
+ if (wantedGate && frame.auth === false && !r.warnedNoGate) {
2030
+ r.warnedNoGate = true;
2031
+ logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`);
2032
+ } else if (frame.auth === true) {
2033
+ r.warnedNoGate = false;
2034
+ }
2035
+ if (credOf(r.url).credential) {
2036
+ const priv = {};
2037
+ const dir = deps.directory ?? "";
2038
+ if (dir)
2039
+ priv.directory = dir;
2040
+ if (deps.worktree)
2041
+ priv.worktree = String(deps.worktree);
2042
+ try {
2043
+ priv.home = homedir();
2044
+ } catch {}
2045
+ if (Object.keys(priv).length) {
2046
+ const seq = ++nextSeq;
2047
+ wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq });
2048
+ }
2049
+ }
1626
2050
  announceUrl(st, r);
1627
2051
  if (!st.wroteBinding) {
1628
2052
  chain = chain.then(() => writeBinding(st)).catch(() => {});
@@ -1833,6 +2257,15 @@ function createEngine(deps) {
1833
2257
  }).catch((e) => logError("flush error", e?.message || e));
1834
2258
  }, wait);
1835
2259
  };
2260
+ const homeDir = (() => {
2261
+ try {
2262
+ return homedir();
2263
+ } catch {
2264
+ return "";
2265
+ }
2266
+ })();
2267
+ const userPatterns = cfg.redact?.patterns;
2268
+ const redactionRules = activeRules(userPatterns);
1836
2269
  const enqueue = (st, entry) => {
1837
2270
  if (!st || !entry)
1838
2271
  return;
@@ -1849,6 +2282,22 @@ function createEngine(deps) {
1849
2282
  }
1850
2283
  if (h)
1851
2284
  st.sentHash.set(entry.key, h);
2285
+ {
2286
+ const gate = authFrame(st);
2287
+ const gated = gate.authLogin !== "" && gate.authPassword !== "";
2288
+ const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated });
2289
+ if (pass.item !== entry.item)
2290
+ entry = { ...entry, item: pass.item };
2291
+ if (pass.hits.length) {
2292
+ const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] };
2293
+ led.count += pass.hits.length;
2294
+ for (const hit of pass.hits) {
2295
+ if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind))
2296
+ led.samples.push(hit);
2297
+ }
2298
+ redactionLedger.set(st.sid, led);
2299
+ }
2300
+ }
1852
2301
  st.queue.set(entry.key, entry.item);
1853
2302
  indexMessage(entry);
1854
2303
  if (debugEvents)
@@ -1857,6 +2306,24 @@ function createEngine(deps) {
1857
2306
  ensureConnected(st);
1858
2307
  scheduleFlush(st);
1859
2308
  };
2309
+ statsProvider = (sid) => {
2310
+ const st = sessions.get(sid);
2311
+ let messages = 0;
2312
+ let toolCalls = 0;
2313
+ if (st) {
2314
+ for (const [, merged] of st.merged) {
2315
+ messages++;
2316
+ const content = merged.content;
2317
+ if (Array.isArray(content))
2318
+ toolCalls += content.filter((c) => !!c && c.type === "tool").length;
2319
+ }
2320
+ for (const it of st.queue.values()) {
2321
+ if (it?.type === "message")
2322
+ messages++;
2323
+ }
2324
+ }
2325
+ return { messages, toolCalls };
2326
+ };
1860
2327
  const msgIndex = new Map;
1861
2328
  const tombstoned = new Set;
1862
2329
  const indexMessage = (entry) => {
@@ -2279,7 +2746,7 @@ function createEngine(deps) {
2279
2746
  const backfillSession = async (st, client, sid, info) => {
2280
2747
  const sessionInfoValue = info ?? await sessionInfo(client, sid);
2281
2748
  if (sessionInfoValue)
2282
- enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
2749
+ enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid));
2283
2750
  let modelSeen = false;
2284
2751
  const consume = (messages) => {
2285
2752
  for (const message of messages) {
@@ -2319,7 +2786,7 @@ function createEngine(deps) {
2319
2786
  if (messages.length === 0 && !info) {
2320
2787
  const fetched = await sessionInfo(client, st.sid);
2321
2788
  if (fetched)
2322
- enqueue(st, itemFor("session", redact(fetched), st.sid));
2789
+ enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid));
2323
2790
  }
2324
2791
  if (typeof client.session.list === "function") {
2325
2792
  try {
@@ -2396,12 +2863,8 @@ function createEngine(deps) {
2396
2863
  if (parent) {
2397
2864
  const st = sessions.get(parent);
2398
2865
  if (st?.activated && !st.closed) {
2399
- if (!st.prefsSent) {
2400
- st.prefsSent = true;
2401
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2402
- }
2403
2866
  if (info)
2404
- enqueue(st, itemFor("session", redact(info), sid));
2867
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2405
2868
  }
2406
2869
  return;
2407
2870
  }
@@ -2410,23 +2873,14 @@ function createEngine(deps) {
2410
2873
  if (flag.enabled) {
2411
2874
  if (st?.activated && !st.closed) {
2412
2875
  if (flag.opts)
2413
- st.opts = flag.opts;
2414
- if (!st.prefsSent) {
2415
- st.prefsSent = true;
2416
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2417
- }
2876
+ applyOpts(st, flag.opts);
2418
2877
  if (info)
2419
- enqueue(st, itemFor("session", redact(info), sid));
2878
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2420
2879
  if (t === "session.updated")
2421
2880
  chain = chain.then(() => refreshOptOuts(st)).catch(() => {});
2422
2881
  return;
2423
2882
  }
2424
2883
  activate(sid, flag.opts, info);
2425
- const fresh = sessions.get(sid);
2426
- if (fresh && !fresh.prefsSent) {
2427
- fresh.prefsSent = true;
2428
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2429
- }
2430
2884
  return;
2431
2885
  }
2432
2886
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2472,12 +2926,8 @@ function createEngine(deps) {
2472
2926
  }
2473
2927
  if (!st)
2474
2928
  return;
2475
- if (!st.prefsSent) {
2476
- st.prefsSent = true;
2477
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2478
- }
2479
2929
  if (info)
2480
- enqueue(st, itemFor("session", redact(info), sid));
2930
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2481
2931
  if (t === "session.updated" && st.activated && !st.closed) {
2482
2932
  chain = chain.then(() => refreshOptOuts(st)).catch(() => {});
2483
2933
  }
@@ -2685,6 +3135,21 @@ function createEngine(deps) {
2685
3135
  st.metaTimer = null;
2686
3136
  await writeBinding(st).catch(() => {});
2687
3137
  }
3138
+ const barrierStart = Date.now();
3139
+ while (Date.now() - barrierStart < 5000) {
3140
+ let pendingAnnounce = false;
3141
+ for (const st of sessions.values()) {
3142
+ for (const r of st.remotes.values()) {
3143
+ if (r.authBlocked || r.collided)
3144
+ continue;
3145
+ if (r.shareId && !r.announced || !r.shareId && (r.ws || r.connecting))
3146
+ pendingAnnounce = true;
3147
+ }
3148
+ }
3149
+ if (!pendingAnnounce)
3150
+ break;
3151
+ await new Promise((res) => setTimeout(res, 100));
3152
+ }
2688
3153
  for (const st of sessions.values()) {
2689
3154
  for (const r of st.remotes.values()) {
2690
3155
  try {