@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/tui.js
CHANGED
|
@@ -22,12 +22,13 @@ var DEFAULTS = {
|
|
|
22
22
|
debounceMs: 1e4,
|
|
23
23
|
completedDelayMs: 5000,
|
|
24
24
|
modelLabel: "$Provider/$Model",
|
|
25
|
-
|
|
26
|
-
openTasksInNewTab: true,
|
|
25
|
+
ttlHours: 90 * 24,
|
|
27
26
|
authLogin: "",
|
|
28
27
|
authPassword: "",
|
|
29
28
|
spoolDir: "",
|
|
30
29
|
urlFile: "",
|
|
30
|
+
urlFileFormat: "url",
|
|
31
|
+
configSet: "",
|
|
31
32
|
announceStderr: true
|
|
32
33
|
};
|
|
33
34
|
var CONFIG_NAME = "openshare.config.json";
|
|
@@ -98,10 +99,13 @@ function configSourceMap(pluginDir, env = process.env) {
|
|
|
98
99
|
debounceMs: env.OPENSHARE_DEBOUNCE_MS,
|
|
99
100
|
completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
|
|
100
101
|
modelLabel: env.OPENSHARE_MODEL_LABEL,
|
|
102
|
+
ttlHours: env.OPENSHARE_TTL_HOURS,
|
|
101
103
|
ttlDays: env.OPENSHARE_TTL_DAYS,
|
|
102
104
|
authLogin: env.OPENSHARE_AUTH_LOGIN,
|
|
103
105
|
authPassword: env.OPENSHARE_AUTH_PASSWORD,
|
|
104
106
|
urlFile: env.OPENSHARE_URL_FILE,
|
|
107
|
+
urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
|
|
108
|
+
configSet: env.OPENSHARE_CONFIG_SET,
|
|
105
109
|
spoolDir: env.OPENSHARE_SPOOL_DIR,
|
|
106
110
|
announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
|
|
107
111
|
shareMode: env.OPENSHARE_SHARE_MODE
|
|
@@ -110,6 +114,24 @@ function configSourceMap(pluginDir, env = process.env) {
|
|
|
110
114
|
if (value !== undefined && value !== "")
|
|
111
115
|
sources[key] = "environment";
|
|
112
116
|
}
|
|
117
|
+
if (sources.ttlDays && !sources.ttlHours)
|
|
118
|
+
sources.ttlHours = sources.ttlDays;
|
|
119
|
+
delete sources.ttlDays;
|
|
120
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
121
|
+
try {
|
|
122
|
+
const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON);
|
|
123
|
+
if (raw && typeof raw === "object") {
|
|
124
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
125
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
126
|
+
continue;
|
|
127
|
+
if (sources[key] === "environment")
|
|
128
|
+
continue;
|
|
129
|
+
if (key === "servers" || known.has(key))
|
|
130
|
+
sources[key] = "OPENSHARE_CONFIG_JSON";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
113
135
|
for (const path of configPaths(pluginDir, env)) {
|
|
114
136
|
let raw;
|
|
115
137
|
try {
|
|
@@ -163,6 +185,8 @@ function normalizeServers(raw) {
|
|
|
163
185
|
entry.adminToken = e.adminToken;
|
|
164
186
|
if (e.active === true)
|
|
165
187
|
entry.active = true;
|
|
188
|
+
if (e.anonymous === true)
|
|
189
|
+
entry.anonymous = true;
|
|
166
190
|
out[url] = entry;
|
|
167
191
|
}
|
|
168
192
|
return Object.keys(out).length ? out : undefined;
|
|
@@ -232,27 +256,57 @@ function loadConfig(pluginDir, options) {
|
|
|
232
256
|
const cfg = { ...DEFAULTS };
|
|
233
257
|
const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
|
|
234
258
|
const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
259
|
+
const applyObject = (raw) => {
|
|
260
|
+
if (!raw || typeof raw !== "object")
|
|
261
|
+
return;
|
|
262
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
263
|
+
if (key === "servers") {
|
|
264
|
+
const servers = normalizeServers(value);
|
|
265
|
+
if (servers)
|
|
266
|
+
cfg.servers = mergeServerGroups(cfg.servers, servers);
|
|
239
267
|
continue;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
continue;
|
|
268
|
+
}
|
|
269
|
+
if (key === "redact") {
|
|
270
|
+
if (value && typeof value === "object" && Array.isArray(value.patterns)) {
|
|
271
|
+
const pats = value.patterns.filter((x) => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50);
|
|
272
|
+
cfg.redact = { patterns: pats };
|
|
246
273
|
}
|
|
247
|
-
|
|
248
|
-
continue;
|
|
249
|
-
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
250
|
-
continue;
|
|
251
|
-
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
252
|
-
continue;
|
|
253
|
-
cfg[key] = value;
|
|
274
|
+
continue;
|
|
254
275
|
}
|
|
255
|
-
|
|
276
|
+
if (key === "meta") {
|
|
277
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
278
|
+
const clean = {};
|
|
279
|
+
for (const [mk, mv] of Object.entries(value)) {
|
|
280
|
+
if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64)
|
|
281
|
+
clean[mk] = mv;
|
|
282
|
+
}
|
|
283
|
+
if (Object.keys(clean).length)
|
|
284
|
+
cfg.meta = { ...cfg.meta, ...clean };
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (key === "ttlDays") {
|
|
289
|
+
const d = Math.floor(Number(value));
|
|
290
|
+
if (Number.isFinite(d) && d > 0)
|
|
291
|
+
cfg.ttlHours = d * 24;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (!known.has(key))
|
|
295
|
+
continue;
|
|
296
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
|
|
297
|
+
continue;
|
|
298
|
+
if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
|
|
299
|
+
continue;
|
|
300
|
+
cfg[key] = value;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
for (const path of [...configPaths(pluginDir)].reverse()) {
|
|
304
|
+
try {
|
|
305
|
+
applyObject(JSON.parse(readFileSync(path, "utf8")));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (existsSync(path))
|
|
308
|
+
console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
|
|
309
|
+
}
|
|
256
310
|
}
|
|
257
311
|
if (options && typeof options === "object") {
|
|
258
312
|
const serversOpt = normalizeServers(options.servers);
|
|
@@ -271,6 +325,13 @@ function loadConfig(pluginDir, options) {
|
|
|
271
325
|
cfg.shareMode = mode;
|
|
272
326
|
}
|
|
273
327
|
const env = process.env;
|
|
328
|
+
if (env.OPENSHARE_CONFIG_JSON) {
|
|
329
|
+
try {
|
|
330
|
+
applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON));
|
|
331
|
+
} catch (e) {
|
|
332
|
+
console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", e?.message ?? e);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
274
335
|
if (env.OPENSHARE_WORKER_URL)
|
|
275
336
|
cfg.workerUrl = env.OPENSHARE_WORKER_URL;
|
|
276
337
|
if (env.OPENSHARE_CREATE_TOKEN)
|
|
@@ -281,14 +342,20 @@ function loadConfig(pluginDir, options) {
|
|
|
281
342
|
cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
|
|
282
343
|
if (env.OPENSHARE_MODEL_LABEL !== undefined)
|
|
283
344
|
cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
|
|
345
|
+
if (env.OPENSHARE_TTL_HOURS)
|
|
346
|
+
cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
|
|
284
347
|
if (env.OPENSHARE_TTL_DAYS)
|
|
285
|
-
cfg.
|
|
348
|
+
cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
|
|
286
349
|
if (env.OPENSHARE_AUTH_LOGIN !== undefined)
|
|
287
350
|
cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
|
|
288
351
|
if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
|
|
289
352
|
cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
|
|
290
353
|
if (env.OPENSHARE_URL_FILE !== undefined)
|
|
291
354
|
cfg.urlFile = env.OPENSHARE_URL_FILE;
|
|
355
|
+
if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url")
|
|
356
|
+
cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT;
|
|
357
|
+
if (env.OPENSHARE_CONFIG_SET)
|
|
358
|
+
cfg.configSet = env.OPENSHARE_CONFIG_SET;
|
|
292
359
|
if (env.OPENSHARE_SPOOL_DIR)
|
|
293
360
|
cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
|
|
294
361
|
if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
|
|
@@ -297,6 +364,14 @@ function loadConfig(pluginDir, options) {
|
|
|
297
364
|
cfg.shareMode = env.OPENSHARE_SHARE_MODE;
|
|
298
365
|
if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
|
|
299
366
|
cfg.urlFile = URL_FILE_NONE;
|
|
367
|
+
if (cfg.workerUrl)
|
|
368
|
+
cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
|
|
369
|
+
if (cfg.servers) {
|
|
370
|
+
const fixed = {};
|
|
371
|
+
for (const [u, e] of Object.entries(cfg.servers))
|
|
372
|
+
fixed[u.trim().replace(/\/+$/, "")] = e;
|
|
373
|
+
cfg.servers = fixed;
|
|
374
|
+
}
|
|
300
375
|
return cfg;
|
|
301
376
|
}
|
|
302
377
|
function globalConfigDir(env) {
|
|
@@ -434,6 +509,22 @@ function queueBindingWrite(sid, fn) {
|
|
|
434
509
|
return run;
|
|
435
510
|
}
|
|
436
511
|
|
|
512
|
+
// src/redact.ts
|
|
513
|
+
var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
|
|
514
|
+
|
|
515
|
+
// src/core.ts
|
|
516
|
+
var liveTargets = [];
|
|
517
|
+
var statsProvider = null;
|
|
518
|
+
var redactionLedger = new Map;
|
|
519
|
+
function sessionStatsFor(sid) {
|
|
520
|
+
const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
|
|
521
|
+
const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
|
|
522
|
+
return { ...counts, redaction: led };
|
|
523
|
+
}
|
|
524
|
+
function engineTargetUrls() {
|
|
525
|
+
return liveTargets;
|
|
526
|
+
}
|
|
527
|
+
|
|
437
528
|
// src/share-flow.ts
|
|
438
529
|
function currentConfig(baseDir = moduleDir()) {
|
|
439
530
|
return loadConfig(baseDir);
|
|
@@ -458,8 +549,7 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
458
549
|
"debounceMs",
|
|
459
550
|
"completedDelayMs",
|
|
460
551
|
"modelLabel",
|
|
461
|
-
"
|
|
462
|
-
"openTasksInNewTab",
|
|
552
|
+
"ttlHours",
|
|
463
553
|
"authLogin",
|
|
464
554
|
"authPassword",
|
|
465
555
|
"spoolDir",
|
|
@@ -490,8 +580,8 @@ function serverEntries(cfg = currentConfig()) {
|
|
|
490
580
|
for (const [url, entry] of Object.entries(cfg.servers))
|
|
491
581
|
merged[url] = { ...merged[url], ...entry };
|
|
492
582
|
const urls = Object.keys(merged);
|
|
493
|
-
const
|
|
494
|
-
return urls.map((url) => ({ url, entry: merged[url], active: url ===
|
|
583
|
+
const anyActive = urls.some((u) => merged[u].active);
|
|
584
|
+
return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
|
|
495
585
|
}
|
|
496
586
|
function bindingsOf(info) {
|
|
497
587
|
const raw = info?.metadata?.[METADATA_KEY];
|
|
@@ -500,6 +590,8 @@ function bindingsOf(info) {
|
|
|
500
590
|
const record = raw;
|
|
501
591
|
const opts = record.opts ?? undefined;
|
|
502
592
|
const enabled = record.enabled === true ? true : undefined;
|
|
593
|
+
const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
|
|
594
|
+
const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
|
|
503
595
|
const out = [];
|
|
504
596
|
if (record.servers && typeof record.servers === "object") {
|
|
505
597
|
for (const [server, entry] of Object.entries(record.servers)) {
|
|
@@ -517,7 +609,10 @@ function bindingsOf(info) {
|
|
|
517
609
|
enabled,
|
|
518
610
|
opts,
|
|
519
611
|
server,
|
|
520
|
-
collided
|
|
612
|
+
collided,
|
|
613
|
+
optedOut: e.optedOut === true ? true : undefined,
|
|
614
|
+
lastError,
|
|
615
|
+
ttlClamped
|
|
521
616
|
});
|
|
522
617
|
}
|
|
523
618
|
return out;
|
|
@@ -532,7 +627,9 @@ function bindingsOf(info) {
|
|
|
532
627
|
enabled,
|
|
533
628
|
opts,
|
|
534
629
|
server: "",
|
|
535
|
-
collided
|
|
630
|
+
collided,
|
|
631
|
+
lastError,
|
|
632
|
+
ttlClamped
|
|
536
633
|
});
|
|
537
634
|
}
|
|
538
635
|
return out;
|
|
@@ -540,6 +637,23 @@ function bindingsOf(info) {
|
|
|
540
637
|
async function readBindings(client, sid) {
|
|
541
638
|
return bindingsOf(await getSession(client, sid));
|
|
542
639
|
}
|
|
640
|
+
function fmtHours(hours) {
|
|
641
|
+
if (!Number.isFinite(hours) || hours <= 0)
|
|
642
|
+
return "?";
|
|
643
|
+
if (hours % 24 === 0)
|
|
644
|
+
return `${Math.floor(hours / 24)}d`;
|
|
645
|
+
return `${Math.floor(hours)}h`;
|
|
646
|
+
}
|
|
647
|
+
function parseTtlHours(raw) {
|
|
648
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
|
|
649
|
+
if (!m)
|
|
650
|
+
return;
|
|
651
|
+
const v = Number(m[1]);
|
|
652
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
653
|
+
return;
|
|
654
|
+
const unit = (m[2] ?? "h").toLowerCase();
|
|
655
|
+
return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
|
|
656
|
+
}
|
|
543
657
|
function hostOf(url) {
|
|
544
658
|
try {
|
|
545
659
|
return new URL(url).host;
|
|
@@ -626,6 +740,20 @@ async function setShareFlag(client, sid, opts) {
|
|
|
626
740
|
metadata[METADATA_KEY] = value;
|
|
627
741
|
return patchMetadata(client, sid, metadata);
|
|
628
742
|
}
|
|
743
|
+
async function setShareOpts(client, sid, opts) {
|
|
744
|
+
const info = await getSession(client, sid);
|
|
745
|
+
if (!info)
|
|
746
|
+
return false;
|
|
747
|
+
const metadata = { ...info.metadata ?? {} };
|
|
748
|
+
const existing = info.metadata?.[METADATA_KEY];
|
|
749
|
+
const value = existing && typeof existing === "object" ? { ...existing } : { v: 2, servers: {} };
|
|
750
|
+
if (Object.keys(opts).length)
|
|
751
|
+
value.opts = opts;
|
|
752
|
+
else
|
|
753
|
+
delete value.opts;
|
|
754
|
+
metadata[METADATA_KEY] = value;
|
|
755
|
+
return patchMetadata(client, sid, metadata);
|
|
756
|
+
}
|
|
629
757
|
async function clearShareFlag(client, sid) {
|
|
630
758
|
const info = await getSession(client, sid);
|
|
631
759
|
if (!info)
|
|
@@ -647,6 +775,27 @@ async function workerDeleteShare(workerUrl, id, secret) {
|
|
|
647
775
|
return false;
|
|
648
776
|
}
|
|
649
777
|
}
|
|
778
|
+
async function patchShareOpts(workerUrl, id, opts, secret) {
|
|
779
|
+
const body = { secret };
|
|
780
|
+
if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
|
|
781
|
+
body.authLogin = opts.authLogin;
|
|
782
|
+
body.authPassword = opts.authPassword;
|
|
783
|
+
}
|
|
784
|
+
if (opts.ttlHours !== undefined)
|
|
785
|
+
body.ttlHours = opts.ttlHours;
|
|
786
|
+
try {
|
|
787
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
788
|
+
method: "PATCH",
|
|
789
|
+
headers: { "content-type": "application/json" },
|
|
790
|
+
body: JSON.stringify(body),
|
|
791
|
+
signal: AbortSignal.timeout(8000)
|
|
792
|
+
});
|
|
793
|
+
const json = await res.json().catch(() => ({}));
|
|
794
|
+
return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
|
|
795
|
+
} catch (e) {
|
|
796
|
+
return { ok: false, error: e?.message || String(e) };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
650
799
|
async function probeWorker(workerUrl) {
|
|
651
800
|
if (!workerUrl)
|
|
652
801
|
return { ok: false, detail: "workerUrl is empty" };
|
|
@@ -786,6 +935,28 @@ function adminClient(workerUrl, token) {
|
|
|
786
935
|
health: () => call("GET", "/health")
|
|
787
936
|
};
|
|
788
937
|
}
|
|
938
|
+
function meClient(workerUrl, token) {
|
|
939
|
+
const base = normServerUrl(workerUrl);
|
|
940
|
+
const call = async (method, path) => {
|
|
941
|
+
try {
|
|
942
|
+
const res = await fetch(`${base}${path}`, {
|
|
943
|
+
method,
|
|
944
|
+
headers: { authorization: `Bearer ${token}` },
|
|
945
|
+
signal: AbortSignal.timeout(12000)
|
|
946
|
+
});
|
|
947
|
+
const parsed = await res.json().catch(() => ({}));
|
|
948
|
+
if (!res.ok)
|
|
949
|
+
return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
|
|
950
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
|
|
951
|
+
} catch (error) {
|
|
952
|
+
return { ok: false, status: 0, error: error.message };
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
return {
|
|
956
|
+
me: () => call("GET", "/api/me"),
|
|
957
|
+
myShares: () => call("GET", "/api/me/shares")
|
|
958
|
+
};
|
|
959
|
+
}
|
|
789
960
|
function adminTokenFor(url, cfg = currentConfig()) {
|
|
790
961
|
const base = normServerUrl(url);
|
|
791
962
|
const entry = serverEntries(cfg).find((s) => s.url === base);
|
|
@@ -802,11 +973,24 @@ async function waitForBinding(client, sid, timeoutMs = 25000) {
|
|
|
802
973
|
}
|
|
803
974
|
return;
|
|
804
975
|
}
|
|
805
|
-
async function requestShareConsent(host) {
|
|
976
|
+
async function requestShareConsent(host, context) {
|
|
977
|
+
const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
|
|
978
|
+
if (context) {
|
|
979
|
+
const bits = [];
|
|
980
|
+
if (context.server)
|
|
981
|
+
bits.push(`server: ${context.server}`);
|
|
982
|
+
if (context.ttlHours !== undefined)
|
|
983
|
+
bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
|
|
984
|
+
if (context.redaction && context.redaction.hits > 0)
|
|
985
|
+
bits.push(`redaction: ${context.redaction.hits} hit(s)`);
|
|
986
|
+
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
987
|
+
lines.push(bits.join(" - "));
|
|
988
|
+
}
|
|
989
|
+
lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
|
|
806
990
|
return host.ui.showConfirm({
|
|
807
991
|
title: "Share Session",
|
|
808
|
-
message: `
|
|
809
|
-
|
|
992
|
+
message: lines.join(`
|
|
993
|
+
`),
|
|
810
994
|
confirmLabel: "Share"
|
|
811
995
|
});
|
|
812
996
|
}
|
|
@@ -814,17 +998,30 @@ async function shareWithConsent(host, sessionID) {
|
|
|
814
998
|
if (!sessionID)
|
|
815
999
|
return { ok: false, message: "No active session to share." };
|
|
816
1000
|
const cfg = currentConfig();
|
|
817
|
-
if (!isConfigured(cfg)) {
|
|
818
|
-
return { ok: false, message: "No share server connected
|
|
1001
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
1002
|
+
return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
|
|
819
1003
|
}
|
|
820
1004
|
const root = await resolveRoot(host.client, sessionID);
|
|
821
|
-
const
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1005
|
+
const all = await readBindings(host.client, root);
|
|
1006
|
+
const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
|
|
1007
|
+
if (live) {
|
|
1008
|
+
await host.clipboard.write(live.url);
|
|
1009
|
+
return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
|
|
1010
|
+
}
|
|
1011
|
+
const opts = all[0]?.opts;
|
|
1012
|
+
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
1013
|
+
{
|
|
1014
|
+
const stats = sessionStatsFor(root);
|
|
1015
|
+
const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
|
|
1016
|
+
if (!await requestShareConsent(host, {
|
|
1017
|
+
server: server ? hostOf(server.url) : undefined,
|
|
1018
|
+
ttlHours: opts?.ttlHours ?? cfg.ttlHours,
|
|
1019
|
+
auth: gated,
|
|
1020
|
+
counts: { messages: stats.messages, toolCalls: stats.toolCalls },
|
|
1021
|
+
redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) }
|
|
1022
|
+
}))
|
|
1023
|
+
return { ok: false, message: "Share cancelled." };
|
|
825
1024
|
}
|
|
826
|
-
if (!await requestShareConsent(host))
|
|
827
|
-
return { ok: false, message: "Share cancelled." };
|
|
828
1025
|
return confirmShare(host, root);
|
|
829
1026
|
}
|
|
830
1027
|
async function confirmShare(host, rootSessionID) {
|
|
@@ -833,12 +1030,23 @@ async function confirmShare(host, rootSessionID) {
|
|
|
833
1030
|
dbgLog(`confirmShare: setShareFlag -> ${set}`);
|
|
834
1031
|
if (!set)
|
|
835
1032
|
return { ok: false, message: "Could not write the share flag into session metadata." };
|
|
1033
|
+
await patchSessionBinding(host.client, rootSessionID, (servers) => {
|
|
1034
|
+
for (const e of Object.values(servers)) {
|
|
1035
|
+
if (e && typeof e === "object")
|
|
1036
|
+
delete e.optedOut;
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
836
1039
|
const binding = await waitForBinding(host.client, rootSessionID);
|
|
837
1040
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
838
1041
|
if (!binding?.url) {
|
|
1042
|
+
await clearShareFlag(host.client, rootSessionID);
|
|
1043
|
+
const fresh = await readBinding(host.client, rootSessionID);
|
|
1044
|
+
const why = fresh?.lastError ? `
|
|
1045
|
+
Reason: ${fresh.lastError}` : "";
|
|
839
1046
|
return {
|
|
840
1047
|
ok: false,
|
|
841
|
-
message:
|
|
1048
|
+
message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}
|
|
1049
|
+
Check the [openshare] stderr lines for details.`
|
|
842
1050
|
};
|
|
843
1051
|
}
|
|
844
1052
|
await host.clipboard.write(binding.url);
|
|
@@ -944,13 +1152,15 @@ Regular users should NOT hold the root token: connect via an access code instead
|
|
|
944
1152
|
},
|
|
945
1153
|
shareMode: {
|
|
946
1154
|
label: "Share mode",
|
|
947
|
-
help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
|
|
1155
|
+
help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
|
|
948
1156
|
kind: "text",
|
|
949
1157
|
options: ["auto", "all", "manual"],
|
|
950
1158
|
section: "Sharing",
|
|
951
|
-
info: `all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared
|
|
1159
|
+
info: `all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared - share those explicitly. manual: sessions stay private until you run /share.
|
|
952
1160
|
|
|
953
|
-
Default (empty): TUI presence decides
|
|
1161
|
+
Default (empty): TUI presence decides - installing the TUI part means manual, server-only means all.
|
|
1162
|
+
|
|
1163
|
+
NOTE: the mode is resolved once at plugin load. Changing it here saves immediately but only takes effect after opencode restarts - until then new sessions keep following the OLD mode.`,
|
|
954
1164
|
def: "auto (TUI presence)"
|
|
955
1165
|
},
|
|
956
1166
|
debounceMs: {
|
|
@@ -981,24 +1191,15 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
|
|
|
981
1191
|
info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
|
|
982
1192
|
def: '"$Provider/$Model"'
|
|
983
1193
|
},
|
|
984
|
-
|
|
985
|
-
label: "TTL (
|
|
986
|
-
help: "Share lifetime
|
|
1194
|
+
ttlHours: {
|
|
1195
|
+
label: "TTL (hours)",
|
|
1196
|
+
help: "Share lifetime in hours (2d writes 48); capped server-side.",
|
|
987
1197
|
kind: "number",
|
|
988
1198
|
section: "Sharing",
|
|
989
|
-
info: `How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's
|
|
1199
|
+
info: `How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.
|
|
990
1200
|
|
|
991
|
-
A per-session override lives in the This-session section; it is sent at create/attach time.`,
|
|
992
|
-
def: "worker cap (
|
|
993
|
-
},
|
|
994
|
-
openTasksInNewTab: {
|
|
995
|
-
label: "Open tasks in new tab",
|
|
996
|
-
help: "Viewer: task rows open subagent pages in a new tab.",
|
|
997
|
-
kind: "text",
|
|
998
|
-
options: ["true", "false"],
|
|
999
|
-
section: "Sharing",
|
|
1000
|
-
info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
|
|
1001
|
-
def: "true"
|
|
1201
|
+
A per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.`,
|
|
1202
|
+
def: "worker cap (2160h = 90d on the reference deployment)"
|
|
1002
1203
|
},
|
|
1003
1204
|
authLogin: {
|
|
1004
1205
|
label: "Read auth: login",
|
|
@@ -1063,10 +1264,8 @@ function helpForKey(key, cfg, sources) {
|
|
|
1063
1264
|
function displayValue(key, cfg) {
|
|
1064
1265
|
if (key === "createToken" || key === "authPassword")
|
|
1065
1266
|
return mask(String(cfg[key] ?? ""));
|
|
1066
|
-
if (key === "openTasksInNewTab")
|
|
1067
|
-
return String(cfg.openTasksInNewTab !== false);
|
|
1068
1267
|
if (key === "announceStderr")
|
|
1069
|
-
return
|
|
1268
|
+
return cfg.announceStderr !== false ? "on" : "off";
|
|
1070
1269
|
const value = cfg[key];
|
|
1071
1270
|
return value === undefined || value === "" ? "(not set)" : String(value);
|
|
1072
1271
|
}
|
|
@@ -1090,7 +1289,7 @@ async function editKey(host, cfg, key) {
|
|
|
1090
1289
|
const next = { ...cfg };
|
|
1091
1290
|
if (key === "shareMode")
|
|
1092
1291
|
next[key] = picked === "auto" ? undefined : picked;
|
|
1093
|
-
else if (key === "
|
|
1292
|
+
else if (key === "announceStderr")
|
|
1094
1293
|
next[key] = picked === "true";
|
|
1095
1294
|
else
|
|
1096
1295
|
next[key] = picked;
|
|
@@ -1125,26 +1324,74 @@ async function connectServerFlow(host) {
|
|
|
1125
1324
|
const url = normServerUrl(raw);
|
|
1126
1325
|
if (!/^https?:\/\//.test(url))
|
|
1127
1326
|
return { ok: false, message: "URL must start with http:// or https://" };
|
|
1128
|
-
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}
|
|
1327
|
+
host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
|
|
1129
1328
|
const probe = await probeAuthMode(url);
|
|
1130
1329
|
if (!probe.ok)
|
|
1131
1330
|
return { ok: false, message: `${url}: ${probe.detail}` };
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1331
|
+
const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
|
|
1332
|
+
const deactivateOthers = () => {
|
|
1333
|
+
for (const other of serverEntries()) {
|
|
1334
|
+
if (other.url !== url)
|
|
1335
|
+
saveServerEntry(other.url, { active: undefined });
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
if (probe.mode === "public") {
|
|
1339
|
+
const picked = await showSelect(host.ui, {
|
|
1340
|
+
title: `${url} - account?`,
|
|
1341
|
+
options: [
|
|
1342
|
+
{
|
|
1343
|
+
value: "onboard",
|
|
1344
|
+
title: "Onboard (get an account)",
|
|
1345
|
+
description: "your own user, tokens and share limits; survives the server's anonymous caps",
|
|
1346
|
+
help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required."
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
value: "anon",
|
|
1350
|
+
title: "Stay anonymous",
|
|
1351
|
+
description: "share without an account; counts against the server-wide anonymous limits",
|
|
1352
|
+
help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now."
|
|
1353
|
+
}
|
|
1354
|
+
]
|
|
1355
|
+
});
|
|
1356
|
+
if (picked === "anon") {
|
|
1357
|
+
saveServerEntry(url, { ...existing.token ? {} : { anonymous: true }, label: existing.label, active: true });
|
|
1358
|
+
return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
|
|
1359
|
+
}
|
|
1360
|
+
if (picked === undefined || picked === null)
|
|
1361
|
+
return { ok: false, message: "Cancelled." };
|
|
1362
|
+
let code = existing.code ?? "";
|
|
1363
|
+
if (!code) {
|
|
1364
|
+
const free = await enrollAtServer(url, "");
|
|
1365
|
+
if (free.ok) {
|
|
1366
|
+
deactivateOthers();
|
|
1367
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
|
|
1368
|
+
return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
|
|
1369
|
+
}
|
|
1370
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1371
|
+
if (typed === undefined)
|
|
1372
|
+
return { ok: false, message: "Cancelled - this server needs a code to onboard." };
|
|
1373
|
+
code = typed.trim();
|
|
1374
|
+
}
|
|
1375
|
+
const res = await enrollAtServer(url, code);
|
|
1376
|
+
if (!res.ok)
|
|
1377
|
+
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
1378
|
+
deactivateOthers();
|
|
1379
|
+
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
|
|
1380
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
1381
|
+
}
|
|
1382
|
+
let code = existing.code ?? "";
|
|
1383
|
+
if (!code) {
|
|
1384
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1135
1385
|
if (typed === undefined)
|
|
1136
|
-
return { ok: false, message: "Cancelled
|
|
1386
|
+
return { ok: false, message: "Cancelled - private servers need a code." };
|
|
1137
1387
|
code = typed.trim();
|
|
1138
1388
|
}
|
|
1139
1389
|
const res = await enrollAtServer(url, code);
|
|
1140
1390
|
if (!res.ok)
|
|
1141
1391
|
return { ok: false, message: `Enrollment refused: ${res.error}` };
|
|
1142
|
-
|
|
1143
|
-
if (other.url !== url)
|
|
1144
|
-
saveServerEntry(other.url, { active: undefined });
|
|
1145
|
-
}
|
|
1392
|
+
deactivateOthers();
|
|
1146
1393
|
saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
|
|
1147
|
-
return { ok: true, message: `Connected to ${url} as "${res.label}"
|
|
1394
|
+
return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
|
|
1148
1395
|
}
|
|
1149
1396
|
async function serverMenu(host, url) {
|
|
1150
1397
|
while (true) {
|
|
@@ -1153,7 +1400,7 @@ async function serverMenu(host, url) {
|
|
|
1153
1400
|
if (!mine)
|
|
1154
1401
|
return;
|
|
1155
1402
|
const holdsRoot = !!mine.entry.token && mine.entry.token === (mine.entry.adminToken ?? adminTokenFor(url));
|
|
1156
|
-
const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : "no token (public server,
|
|
1403
|
+
const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : mine.entry.anonymous ? "anonymous (declined onboarding)" : "no token (public server, shares anonymously)";
|
|
1157
1404
|
const options = [
|
|
1158
1405
|
{ title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} \xB7 ${cred}${mine.active ? " \xB7 ACTIVE" : ""}`, disabled: true }
|
|
1159
1406
|
];
|
|
@@ -1173,6 +1420,8 @@ This creates your own user on ${url} (label prompt, prefilled), mints its token,
|
|
|
1173
1420
|
options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
|
|
1174
1421
|
if (mine.entry.token && !holdsRoot)
|
|
1175
1422
|
options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
|
|
1423
|
+
if (mine.entry.token && !holdsRoot)
|
|
1424
|
+
options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
|
|
1176
1425
|
if (mine.entry.token && !holdsRoot)
|
|
1177
1426
|
options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
|
|
1178
1427
|
options.push({
|
|
@@ -1190,12 +1439,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1190
1439
|
if (choice === undefined || choice === "__back")
|
|
1191
1440
|
return;
|
|
1192
1441
|
if (choice === "active") {
|
|
1193
|
-
for (const other of entries) {
|
|
1194
|
-
if (other.url !== url)
|
|
1195
|
-
saveServerEntry(other.url, { active: undefined });
|
|
1196
|
-
}
|
|
1197
1442
|
saveServerEntry(url, { active: true });
|
|
1198
|
-
|
|
1443
|
+
const stillActive = serverEntries().filter((s) => s.active);
|
|
1444
|
+
host.ui.toast({
|
|
1445
|
+
variant: "success",
|
|
1446
|
+
title: "OpenShare",
|
|
1447
|
+
message: stillActive.length > 1 ? `${url} is primary - ${stillActive.length} servers stay ACTIVE (fan-out): new sessions mirror to all of them.` : `${url} is now the active server.`
|
|
1448
|
+
});
|
|
1199
1449
|
return;
|
|
1200
1450
|
}
|
|
1201
1451
|
if (choice === "adopt") {
|
|
@@ -1230,8 +1480,18 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1230
1480
|
continue;
|
|
1231
1481
|
}
|
|
1232
1482
|
let code = mine.entry.code ?? "";
|
|
1233
|
-
if (
|
|
1234
|
-
const
|
|
1483
|
+
if (!code) {
|
|
1484
|
+
const free = await enrollAtServer(url, "");
|
|
1485
|
+
if (free.ok) {
|
|
1486
|
+
saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
|
|
1487
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
if (!/code required/i.test(free.error ?? "")) {
|
|
1491
|
+
await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
|
|
1492
|
+
continue;
|
|
1493
|
+
}
|
|
1494
|
+
const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
|
|
1235
1495
|
if (typed === undefined)
|
|
1236
1496
|
continue;
|
|
1237
1497
|
code = typed.trim();
|
|
@@ -1240,13 +1500,41 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1240
1500
|
}
|
|
1241
1501
|
const res = await enrollAtServer(url, code);
|
|
1242
1502
|
if (res.ok) {
|
|
1243
|
-
saveServerEntry(url, { token: res.token, label: res.label });
|
|
1503
|
+
saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
|
|
1244
1504
|
host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
|
|
1245
1505
|
} else {
|
|
1246
1506
|
await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
|
|
1247
1507
|
}
|
|
1248
1508
|
continue;
|
|
1249
1509
|
}
|
|
1510
|
+
if (choice === "account") {
|
|
1511
|
+
const me = meClient(url, mine.entry.token ?? "");
|
|
1512
|
+
const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
|
|
1513
|
+
if (!meRes.ok) {
|
|
1514
|
+
await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.
|
|
1515
|
+
If the token was revoked server-side, use Enroll now instead.` });
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
const d = meRes.data;
|
|
1519
|
+
const lim = d.token?.limits ?? {};
|
|
1520
|
+
const fmt = (v) => v ? String(v) : "unlimited";
|
|
1521
|
+
const lines = [
|
|
1522
|
+
`User: ${d.label} (${d.userId})`,
|
|
1523
|
+
`Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
|
|
1524
|
+
`Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`
|
|
1525
|
+
];
|
|
1526
|
+
if (sharesRes.ok) {
|
|
1527
|
+
const shares = sharesRes.data?.shares ?? [];
|
|
1528
|
+
lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
|
|
1529
|
+
for (const s of shares.slice(0, 10))
|
|
1530
|
+
lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
|
|
1531
|
+
if (shares.length > 10)
|
|
1532
|
+
lines.push(` ...and ${shares.length - 10} more`);
|
|
1533
|
+
}
|
|
1534
|
+
await showInfo(host.ui, { title: "Your account", message: lines.join(`
|
|
1535
|
+
`) });
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1250
1538
|
if (choice === "rotate") {
|
|
1251
1539
|
const res = await rotateAtServer(url, mine.entry.token ?? "");
|
|
1252
1540
|
if (res.ok && res.token) {
|
|
@@ -1258,8 +1546,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
|
|
|
1258
1546
|
continue;
|
|
1259
1547
|
}
|
|
1260
1548
|
if (choice === "disconnect") {
|
|
1261
|
-
const sure = await showConfirm(host.ui, {
|
|
1262
|
-
|
|
1549
|
+
const sure = await showConfirm(host.ui, {
|
|
1550
|
+
title: "Disconnect",
|
|
1551
|
+
message: `Revoke the token at ${url} and forget it?
|
|
1552
|
+
Revoking your last token freezes control of ALL your shares there (even with the share secrets) - pages stay readable until their TTL; only the server admin can restore control.`,
|
|
1553
|
+
confirmLabel: "Disconnect",
|
|
1554
|
+
danger: true
|
|
1555
|
+
});
|
|
1263
1556
|
if (!sure)
|
|
1264
1557
|
continue;
|
|
1265
1558
|
const res = await disconnectAtServer(url, mine.entry.token ?? "");
|
|
@@ -1672,7 +1965,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
|
|
|
1672
1965
|
}
|
|
1673
1966
|
}
|
|
1674
1967
|
}
|
|
1675
|
-
async function settingsDialog(host, startAtSession) {
|
|
1968
|
+
async function settingsDialog(host, startAtSession, startGroup) {
|
|
1676
1969
|
let cfg = currentConfig();
|
|
1677
1970
|
let dirty = false;
|
|
1678
1971
|
const candidates = writeCandidates(moduleDir());
|
|
@@ -1854,19 +2147,32 @@ ${otherServers.map((s) => `\u2022 ${s.url}`).join(`
|
|
|
1854
2147
|
}
|
|
1855
2148
|
}
|
|
1856
2149
|
const binding = bindings[0];
|
|
2150
|
+
if (binding?.lastError || binding?.ttlClamped) {
|
|
2151
|
+
const bits = [];
|
|
2152
|
+
if (binding?.ttlClamped)
|
|
2153
|
+
bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
|
|
2154
|
+
if (binding?.lastError)
|
|
2155
|
+
bits.push(`last share error: ${binding.lastError}`);
|
|
2156
|
+
rows.push({ title: "Share status notes", value: "__notes", description: bits[0].slice(0, 60), disabled: true, help: `Engine-recorded notes for this session's share(s):
|
|
2157
|
+
|
|
2158
|
+
${bits.join(`
|
|
2159
|
+
`)}
|
|
2160
|
+
|
|
2161
|
+
The clamp note appears when the server or your token's policy reduced a requested TTL. The error note is the engine's last create/attach refusal (quota, freeze, rate limit) - it clears on the next successful sync.` });
|
|
2162
|
+
}
|
|
1857
2163
|
rows.push({
|
|
1858
2164
|
title: "Session TTL override (days)",
|
|
1859
2165
|
value: "session:ttl",
|
|
1860
|
-
description: binding?.opts?.
|
|
1861
|
-
help: `Per-session share lifetime. Overrides the config
|
|
2166
|
+
description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
|
|
2167
|
+
help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.
|
|
1862
2168
|
|
|
1863
|
-
Current: ${binding?.opts?.
|
|
2169
|
+
Current: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none \u2014 config default)"}`
|
|
1864
2170
|
});
|
|
1865
2171
|
rows.push({
|
|
1866
2172
|
title: "Session read auth",
|
|
1867
2173
|
value: "session:auth",
|
|
1868
2174
|
description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
|
|
1869
|
-
help: `Login gate for THIS session's page only \u2014 viewers must log in (7-day cookie, lockout). Overrides the config read auth.
|
|
2175
|
+
help: `Login gate for THIS session's page only \u2014 viewers must log in (7-day cookie, lockout). Overrides the config read auth; a login and password are both required (or neither). On a live share it applies immediately; otherwise when you share. Removing the login removes the gate.
|
|
1870
2176
|
|
|
1871
2177
|
Current: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none \u2014 config default applies)"}`
|
|
1872
2178
|
});
|
|
@@ -2049,11 +2355,9 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2049
2355
|
});
|
|
2050
2356
|
if (!pick || pick === "__cancel")
|
|
2051
2357
|
return;
|
|
2052
|
-
for (const s of serverEntries())
|
|
2053
|
-
if (s.url !== pick)
|
|
2054
|
-
saveServerEntry(s.url, { active: undefined });
|
|
2055
2358
|
saveServerEntry(pick, { active: true });
|
|
2056
|
-
|
|
2359
|
+
const nActive = serverEntries().filter((s) => s.active).length;
|
|
2360
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
|
|
2057
2361
|
return;
|
|
2058
2362
|
}
|
|
2059
2363
|
if (choice === "session:unshare") {
|
|
@@ -2068,61 +2372,89 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2068
2372
|
if (how === undefined || how === "__cancel")
|
|
2069
2373
|
return;
|
|
2070
2374
|
if (how === "leave") {
|
|
2071
|
-
const
|
|
2375
|
+
const allB = await readBindings(host.client, root);
|
|
2072
2376
|
const okPatch = await patchSessionBinding(host.client, root, (servers) => {
|
|
2073
|
-
for (const b of
|
|
2377
|
+
for (const b of allB) {
|
|
2074
2378
|
if (!b.server)
|
|
2075
2379
|
continue;
|
|
2076
|
-
|
|
2077
|
-
servers[b.server] = { optedOut: true };
|
|
2380
|
+
servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
|
|
2078
2381
|
}
|
|
2079
2382
|
});
|
|
2080
|
-
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs." : "Could not update the binding." });
|
|
2383
|
+
host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs (delete stays available here)." : "Could not update the binding." });
|
|
2081
2384
|
return;
|
|
2082
2385
|
}
|
|
2386
|
+
const sureAll = await showConfirm(host.ui, {
|
|
2387
|
+
title: "Delete every page?",
|
|
2388
|
+
message: `Permanently delete ${live.length} share page(s) and stop sharing?
|
|
2389
|
+
Every link stops working for everyone. This cannot be undone.`,
|
|
2390
|
+
confirmLabel: "Delete every page",
|
|
2391
|
+
danger: true
|
|
2392
|
+
});
|
|
2393
|
+
if (!sureAll)
|
|
2394
|
+
return;
|
|
2083
2395
|
const result = await runUnshareFlow(host, startAtSession);
|
|
2084
2396
|
host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
|
|
2085
2397
|
return;
|
|
2086
2398
|
}
|
|
2087
2399
|
if (choice === "session:ttl") {
|
|
2088
|
-
const raw = await showPrompt(host.ui, { title: "Session TTL
|
|
2400
|
+
const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
|
|
2089
2401
|
if (raw === undefined)
|
|
2090
2402
|
return;
|
|
2091
2403
|
const opts = { ...current?.opts ?? {} };
|
|
2092
2404
|
if (raw === "")
|
|
2093
|
-
delete opts.
|
|
2405
|
+
delete opts.ttlHours;
|
|
2094
2406
|
else {
|
|
2095
|
-
const
|
|
2096
|
-
if (
|
|
2097
|
-
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of
|
|
2407
|
+
const hours = parseTtlHours(raw);
|
|
2408
|
+
if (hours === undefined) {
|
|
2409
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
|
|
2098
2410
|
return;
|
|
2099
2411
|
}
|
|
2100
|
-
opts.
|
|
2412
|
+
opts.ttlHours = hours;
|
|
2101
2413
|
}
|
|
2102
|
-
await
|
|
2103
|
-
|
|
2414
|
+
await setShareOpts(host.client, root, opts);
|
|
2415
|
+
const liveNow = live.length > 0;
|
|
2416
|
+
if (liveNow) {
|
|
2417
|
+
const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })));
|
|
2418
|
+
const failed = results.filter((r) => !r.ok).length;
|
|
2419
|
+
if (failed)
|
|
2420
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
|
|
2421
|
+
}
|
|
2422
|
+
host.ui.toast({
|
|
2423
|
+
variant: "success",
|
|
2424
|
+
title: "OpenShare",
|
|
2425
|
+
message: liveNow ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.` : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`
|
|
2426
|
+
});
|
|
2104
2427
|
return;
|
|
2105
2428
|
}
|
|
2106
2429
|
if (choice === "session:auth") {
|
|
2107
|
-
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty =
|
|
2430
|
+
const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
|
|
2108
2431
|
if (login === undefined)
|
|
2109
2432
|
return;
|
|
2110
|
-
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "
|
|
2433
|
+
const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
|
|
2111
2434
|
if (password === undefined)
|
|
2112
2435
|
return;
|
|
2436
|
+
if (login === "" !== (password === "")) {
|
|
2437
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2113
2440
|
const opts = { ...current?.opts ?? {} };
|
|
2114
2441
|
if (login === "") {
|
|
2115
2442
|
delete opts.authLogin;
|
|
2116
2443
|
delete opts.authPassword;
|
|
2117
2444
|
} else {
|
|
2118
2445
|
opts.authLogin = login;
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2446
|
+
opts.authPassword = password;
|
|
2447
|
+
}
|
|
2448
|
+
await setShareOpts(host.client, root, opts);
|
|
2449
|
+
const liveNow2 = live.length > 0;
|
|
2450
|
+
if (liveNow2) {
|
|
2451
|
+
const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
|
|
2452
|
+
const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })));
|
|
2453
|
+
const failed2 = results2.filter((r) => !r.ok).length;
|
|
2454
|
+
if (failed2)
|
|
2455
|
+
host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` });
|
|
2123
2456
|
}
|
|
2124
|
-
|
|
2125
|
-
host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." });
|
|
2457
|
+
host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` });
|
|
2126
2458
|
return;
|
|
2127
2459
|
}
|
|
2128
2460
|
}
|
|
@@ -2139,6 +2471,8 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
|
|
|
2139
2471
|
await dispatch(choice);
|
|
2140
2472
|
}
|
|
2141
2473
|
};
|
|
2474
|
+
if (startAtSession && startGroup === "session")
|
|
2475
|
+
await groupMenu("session");
|
|
2142
2476
|
const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
|
|
2143
2477
|
while (true) {
|
|
2144
2478
|
const servers = serverEntries(cfg);
|
|
@@ -2170,7 +2504,7 @@ Every ACTIVE server receives new sessions (fan-out).`
|
|
|
2170
2504
|
});
|
|
2171
2505
|
groups.push({
|
|
2172
2506
|
id: "sharing",
|
|
2173
|
-
summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${cfg.
|
|
2507
|
+
summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
|
|
2174
2508
|
help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.
|
|
2175
2509
|
|
|
2176
2510
|
Cadence values are clamped by the server's policy window.`
|
|
@@ -2363,7 +2697,7 @@ function buildShareCommand(host, sessionID) {
|
|
|
2363
2697
|
dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
|
|
2364
2698
|
host.ui.dialog.clear();
|
|
2365
2699
|
const cfg = currentConfig();
|
|
2366
|
-
if (!isConfigured(cfg)) {
|
|
2700
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
2367
2701
|
onboardingDialog(host);
|
|
2368
2702
|
return;
|
|
2369
2703
|
}
|
|
@@ -2378,7 +2712,7 @@ function buildShareCommand(host, sessionID) {
|
|
|
2378
2712
|
return;
|
|
2379
2713
|
const { all, goneShown } = swept;
|
|
2380
2714
|
if (all.some((b) => b.url && b.enabled !== false)) {
|
|
2381
|
-
await settingsDialog(host, sid);
|
|
2715
|
+
await settingsDialog(host, sid, "session");
|
|
2382
2716
|
return;
|
|
2383
2717
|
}
|
|
2384
2718
|
if (goneShown && all.length === 0)
|