@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/share-flow.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { saveServerEntry, deleteServerEntry, type ShareConfig, type ServerEntry } from "./config.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type ClientApi, type SessionInfo, type SessionOpts } from "./core.js";
|
|
3
3
|
import type { ShareHostApi } from "./tui-host.js";
|
|
4
4
|
export type SessionBinding = {
|
|
5
5
|
id: string;
|
|
@@ -8,14 +8,22 @@ export type SessionBinding = {
|
|
|
8
8
|
url: string;
|
|
9
9
|
enabled?: boolean;
|
|
10
10
|
opts?: SessionOpts;
|
|
11
|
+
/** per-server "leave as-is" marker: a snapshot, not a live mirror */
|
|
12
|
+
optedOut?: boolean;
|
|
13
|
+
/** engine-recorded last create/attach refusal (quota, frozen, ...) */
|
|
14
|
+
lastError?: string;
|
|
15
|
+
/** server policy clamped the requested TTL */
|
|
16
|
+
ttlClamped?: {
|
|
17
|
+
requested: number;
|
|
18
|
+
effective: number;
|
|
19
|
+
};
|
|
11
20
|
};
|
|
12
21
|
export declare function pluginConfigPaths(baseDir?: string): string[];
|
|
13
22
|
export declare function currentConfig(baseDir?: string): ShareConfig;
|
|
14
|
-
/** Writes the config file. Target rule
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* keys preserved. */
|
|
23
|
+
/** Writes the config file. Target rule: the global settings home by
|
|
24
|
+
* default, or an explicit existing file chosen in the TUI, never "first
|
|
25
|
+
* existing in the read chain" (that writes TUI edits into the tracked
|
|
26
|
+
* adjacent template). $comment and foreign keys preserved. */
|
|
19
27
|
export declare function saveConfig(cfg: ShareConfig, target?: string, baseDir?: string): {
|
|
20
28
|
path: string;
|
|
21
29
|
} | undefined;
|
|
@@ -42,6 +50,11 @@ export declare function readBindings(client: ClientApi | undefined, sid: string)
|
|
|
42
50
|
server: string;
|
|
43
51
|
collided?: string;
|
|
44
52
|
}>>;
|
|
53
|
+
/** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
|
|
54
|
+
* these same shapes back). */
|
|
55
|
+
export declare function fmtHours(hours: number): string;
|
|
56
|
+
/** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
|
|
57
|
+
export declare function parseTtlHours(raw: string): number | undefined;
|
|
45
58
|
export declare function hostOf(url: string): string;
|
|
46
59
|
/** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
|
|
47
60
|
* server actions: opt-out entries, collision resolution). Reads-modifies-
|
|
@@ -55,10 +68,29 @@ export declare function resolveRoot(client: ClientApi | undefined, sid: string):
|
|
|
55
68
|
export declare function readBinding(client: ClientApi | undefined, sid: string): Promise<SessionBinding | undefined>;
|
|
56
69
|
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
57
70
|
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
58
|
-
* to manage; the flag just opts the session in.
|
|
71
|
+
* to manage; the flag just opts the session in. CONSENT ONLY: every call
|
|
72
|
+
* here means the user agreed to publish. */
|
|
59
73
|
export declare function setShareFlag(client: ClientApi | undefined, sid: string, opts?: SessionOpts): Promise<boolean>;
|
|
74
|
+
/** Persists per-session opts WITHOUT touching the enabled state. On an
|
|
75
|
+
* unshared session the opts sit dormant until a consented create consumes
|
|
76
|
+
* them; on a live share the engine picks them up and re-syncs the server
|
|
77
|
+
* (auth/TTL apply immediately). This must never publish anything - the
|
|
78
|
+
* TTL/auth editors use it, and consent is the only path to enabled. */
|
|
79
|
+
export declare function setShareOpts(client: ClientApi | undefined, sid: string, opts: SessionOpts): Promise<boolean>;
|
|
60
80
|
export declare function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean>;
|
|
61
81
|
export declare function workerDeleteShare(workerUrl: string, id: string, secret: string): Promise<boolean>;
|
|
82
|
+
/** PATCH a live share's settings over REST (#13): immediate effect with
|
|
83
|
+
* no socket required (the engine's attach-sync remains the structural
|
|
84
|
+
* path - this is the same edit pushed now, per server). Auth sends as
|
|
85
|
+
* the both-or-none pair or not at all; ttlHours only when explicit. */
|
|
86
|
+
export declare function patchShareOpts(workerUrl: string, id: string, opts: {
|
|
87
|
+
authLogin?: string;
|
|
88
|
+
authPassword?: string;
|
|
89
|
+
ttlHours?: number;
|
|
90
|
+
}, secret: string): Promise<{
|
|
91
|
+
ok: boolean;
|
|
92
|
+
error?: string;
|
|
93
|
+
}>;
|
|
62
94
|
/** Health probe for the settings dialog's connection test. */
|
|
63
95
|
export declare function probeWorker(workerUrl: string): Promise<{
|
|
64
96
|
ok: boolean;
|
|
@@ -268,6 +300,40 @@ export declare function adminClient(workerUrl: string, token: string): {
|
|
|
268
300
|
error?: string;
|
|
269
301
|
}>;
|
|
270
302
|
};
|
|
303
|
+
/** Self-service client for the /api/me surface (the entry's user osk_
|
|
304
|
+
* token - NOT the root token). Answers "what are my limits, how many
|
|
305
|
+
* shares do I have, when does my token expire". */
|
|
306
|
+
export declare function meClient(workerUrl: string, token: string): {
|
|
307
|
+
me: () => Promise<{
|
|
308
|
+
ok: boolean;
|
|
309
|
+
status: number;
|
|
310
|
+
data?: {
|
|
311
|
+
userId: string;
|
|
312
|
+
label: string;
|
|
313
|
+
token: {
|
|
314
|
+
id: string;
|
|
315
|
+
label: string;
|
|
316
|
+
limits: Record<string, number>;
|
|
317
|
+
expiresAt: number;
|
|
318
|
+
};
|
|
319
|
+
settings: Record<string, unknown>;
|
|
320
|
+
} | undefined;
|
|
321
|
+
error?: string;
|
|
322
|
+
}>;
|
|
323
|
+
myShares: () => Promise<{
|
|
324
|
+
ok: boolean;
|
|
325
|
+
status: number;
|
|
326
|
+
data?: {
|
|
327
|
+
shares: Array<{
|
|
328
|
+
id: string;
|
|
329
|
+
sessionID: string;
|
|
330
|
+
createdAt: number;
|
|
331
|
+
status: string;
|
|
332
|
+
}>;
|
|
333
|
+
} | undefined;
|
|
334
|
+
error?: string;
|
|
335
|
+
}>;
|
|
336
|
+
};
|
|
271
337
|
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
272
338
|
* legacy createToken (when it points at this server), or prompt. */
|
|
273
339
|
export declare function adminTokenFor(url: string, cfg?: ShareConfig): string;
|
|
@@ -281,8 +347,22 @@ export type FlowResult = {
|
|
|
281
347
|
unconfigured?: boolean;
|
|
282
348
|
};
|
|
283
349
|
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
284
|
-
* directive — no remembered consent, no silent creates).
|
|
285
|
-
|
|
350
|
+
* directive — no remembered consent, no silent creates). The dialog
|
|
351
|
+
* carries the live context (server, TTL, gate state) so the decision is
|
|
352
|
+
* informed, not ceremonial. */
|
|
353
|
+
export declare function requestShareConsent(host: ShareHostApi, context?: {
|
|
354
|
+
server?: string;
|
|
355
|
+
ttlHours?: number;
|
|
356
|
+
auth: boolean;
|
|
357
|
+
counts?: {
|
|
358
|
+
messages: number;
|
|
359
|
+
toolCalls: number;
|
|
360
|
+
};
|
|
361
|
+
redaction?: {
|
|
362
|
+
hits: number;
|
|
363
|
+
samples: string[];
|
|
364
|
+
};
|
|
365
|
+
}): Promise<boolean>;
|
|
286
366
|
/** runShareFlow with the consent gate applied: share copies the link,
|
|
287
367
|
* unshared asks consent once, then creates. Every create path routes
|
|
288
368
|
* through here (/share, settings, gone-again). */
|
package/dist/share-flow.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY } from "./config.js";
|
|
18
18
|
import { dbg, dbgLog } from "./sidebar.js";
|
|
19
|
+
import { engineTargetUrls, sessionStatsFor } from "./core.js";
|
|
19
20
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
20
21
|
export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
21
22
|
return configPaths(baseDir);
|
|
@@ -23,11 +24,10 @@ export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
|
23
24
|
export function currentConfig(baseDir = moduleDir()) {
|
|
24
25
|
return loadConfig(baseDir);
|
|
25
26
|
}
|
|
26
|
-
/** Writes the config file. Target rule
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* keys preserved. */
|
|
27
|
+
/** Writes the config file. Target rule: the global settings home by
|
|
28
|
+
* default, or an explicit existing file chosen in the TUI, never "first
|
|
29
|
+
* existing in the read chain" (that writes TUI edits into the tracked
|
|
30
|
+
* adjacent template). $comment and foreign keys preserved. */
|
|
31
31
|
export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
32
32
|
const path = target ?? configHomePath();
|
|
33
33
|
if (!path)
|
|
@@ -52,8 +52,7 @@ export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
52
52
|
"debounceMs",
|
|
53
53
|
"completedDelayMs",
|
|
54
54
|
"modelLabel",
|
|
55
|
-
"
|
|
56
|
-
"openTasksInNewTab",
|
|
55
|
+
"ttlHours",
|
|
57
56
|
"authLogin",
|
|
58
57
|
"authPassword",
|
|
59
58
|
"spoolDir",
|
|
@@ -88,8 +87,10 @@ export function serverEntries(cfg = currentConfig()) {
|
|
|
88
87
|
for (const [url, entry] of Object.entries(cfg.servers))
|
|
89
88
|
merged[url] = { ...merged[url], ...entry };
|
|
90
89
|
const urls = Object.keys(merged);
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
// EVERY explicit active is active (the engine fans out to all of them);
|
|
91
|
+
// only when none is marked does the first entry act as the default
|
|
92
|
+
const anyActive = urls.some((u) => merged[u].active);
|
|
93
|
+
return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
|
|
93
94
|
}
|
|
94
95
|
export { saveServerEntry, deleteServerEntry };
|
|
95
96
|
// --- session metadata helpers -------------------------------------------------
|
|
@@ -101,6 +102,8 @@ export function bindingsOf(info) {
|
|
|
101
102
|
const record = raw;
|
|
102
103
|
const opts = record.opts ?? undefined;
|
|
103
104
|
const enabled = record.enabled === true ? true : undefined;
|
|
105
|
+
const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
|
|
106
|
+
const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
|
|
104
107
|
const out = [];
|
|
105
108
|
if (record.servers && typeof record.servers === "object") {
|
|
106
109
|
for (const [server, entry] of Object.entries(record.servers)) {
|
|
@@ -119,6 +122,9 @@ export function bindingsOf(info) {
|
|
|
119
122
|
opts,
|
|
120
123
|
server,
|
|
121
124
|
collided,
|
|
125
|
+
optedOut: e.optedOut === true ? true : undefined,
|
|
126
|
+
lastError,
|
|
127
|
+
ttlClamped,
|
|
122
128
|
});
|
|
123
129
|
}
|
|
124
130
|
return out;
|
|
@@ -135,6 +141,8 @@ export function bindingsOf(info) {
|
|
|
135
141
|
opts,
|
|
136
142
|
server: "",
|
|
137
143
|
collided,
|
|
144
|
+
lastError,
|
|
145
|
+
ttlClamped,
|
|
138
146
|
});
|
|
139
147
|
}
|
|
140
148
|
return out;
|
|
@@ -143,6 +151,26 @@ export function bindingsOf(info) {
|
|
|
143
151
|
export async function readBindings(client, sid) {
|
|
144
152
|
return bindingsOf(await getSession(client, sid));
|
|
145
153
|
}
|
|
154
|
+
/** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
|
|
155
|
+
* these same shapes back). */
|
|
156
|
+
export function fmtHours(hours) {
|
|
157
|
+
if (!Number.isFinite(hours) || hours <= 0)
|
|
158
|
+
return "?";
|
|
159
|
+
if (hours % 24 === 0)
|
|
160
|
+
return `${Math.floor(hours / 24)}d`;
|
|
161
|
+
return `${Math.floor(hours)}h`;
|
|
162
|
+
}
|
|
163
|
+
/** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
|
|
164
|
+
export function parseTtlHours(raw) {
|
|
165
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
|
|
166
|
+
if (!m)
|
|
167
|
+
return undefined;
|
|
168
|
+
const v = Number(m[1]);
|
|
169
|
+
if (!Number.isFinite(v) || v <= 0)
|
|
170
|
+
return undefined;
|
|
171
|
+
const unit = (m[2] ?? "h").toLowerCase();
|
|
172
|
+
return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
|
|
173
|
+
}
|
|
146
174
|
export function hostOf(url) {
|
|
147
175
|
try {
|
|
148
176
|
return new URL(url).host;
|
|
@@ -230,7 +258,8 @@ export async function readBinding(client, sid) {
|
|
|
230
258
|
}
|
|
231
259
|
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
232
260
|
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
233
|
-
* to manage; the flag just opts the session in.
|
|
261
|
+
* to manage; the flag just opts the session in. CONSENT ONLY: every call
|
|
262
|
+
* here means the user agreed to publish. */
|
|
234
263
|
export async function setShareFlag(client, sid, opts) {
|
|
235
264
|
const info = await getSession(client, sid);
|
|
236
265
|
if (!info)
|
|
@@ -246,6 +275,27 @@ export async function setShareFlag(client, sid, opts) {
|
|
|
246
275
|
metadata[METADATA_KEY] = value;
|
|
247
276
|
return patchMetadata(client, sid, metadata);
|
|
248
277
|
}
|
|
278
|
+
/** Persists per-session opts WITHOUT touching the enabled state. On an
|
|
279
|
+
* unshared session the opts sit dormant until a consented create consumes
|
|
280
|
+
* them; on a live share the engine picks them up and re-syncs the server
|
|
281
|
+
* (auth/TTL apply immediately). This must never publish anything - the
|
|
282
|
+
* TTL/auth editors use it, and consent is the only path to enabled. */
|
|
283
|
+
export async function setShareOpts(client, sid, opts) {
|
|
284
|
+
const info = await getSession(client, sid);
|
|
285
|
+
if (!info)
|
|
286
|
+
return false;
|
|
287
|
+
const metadata = { ...(info.metadata ?? {}) };
|
|
288
|
+
const existing = info.metadata?.[METADATA_KEY];
|
|
289
|
+
const value = existing && typeof existing === "object"
|
|
290
|
+
? { ...existing }
|
|
291
|
+
: { v: 2, servers: {} };
|
|
292
|
+
if (Object.keys(opts).length)
|
|
293
|
+
value.opts = opts;
|
|
294
|
+
else
|
|
295
|
+
delete value.opts;
|
|
296
|
+
metadata[METADATA_KEY] = value;
|
|
297
|
+
return patchMetadata(client, sid, metadata);
|
|
298
|
+
}
|
|
249
299
|
export async function clearShareFlag(client, sid) {
|
|
250
300
|
const info = await getSession(client, sid);
|
|
251
301
|
if (!info)
|
|
@@ -269,6 +319,32 @@ export async function workerDeleteShare(workerUrl, id, secret) {
|
|
|
269
319
|
return false;
|
|
270
320
|
}
|
|
271
321
|
}
|
|
322
|
+
/** PATCH a live share's settings over REST (#13): immediate effect with
|
|
323
|
+
* no socket required (the engine's attach-sync remains the structural
|
|
324
|
+
* path - this is the same edit pushed now, per server). Auth sends as
|
|
325
|
+
* the both-or-none pair or not at all; ttlHours only when explicit. */
|
|
326
|
+
export async function patchShareOpts(workerUrl, id, opts, secret) {
|
|
327
|
+
const body = { secret };
|
|
328
|
+
if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
|
|
329
|
+
body.authLogin = opts.authLogin;
|
|
330
|
+
body.authPassword = opts.authPassword;
|
|
331
|
+
}
|
|
332
|
+
if (opts.ttlHours !== undefined)
|
|
333
|
+
body.ttlHours = opts.ttlHours;
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
|
|
336
|
+
method: "PATCH",
|
|
337
|
+
headers: { "content-type": "application/json" },
|
|
338
|
+
body: JSON.stringify(body),
|
|
339
|
+
signal: AbortSignal.timeout(8000),
|
|
340
|
+
});
|
|
341
|
+
const json = (await res.json().catch(() => ({})));
|
|
342
|
+
return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
return { ok: false, error: e?.message || String(e) };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
272
348
|
/** Health probe for the settings dialog's connection test. */
|
|
273
349
|
export async function probeWorker(workerUrl) {
|
|
274
350
|
if (!workerUrl)
|
|
@@ -430,6 +506,32 @@ export function adminClient(workerUrl, token) {
|
|
|
430
506
|
health: () => call("GET", "/health"),
|
|
431
507
|
};
|
|
432
508
|
}
|
|
509
|
+
/** Self-service client for the /api/me surface (the entry's user osk_
|
|
510
|
+
* token - NOT the root token). Answers "what are my limits, how many
|
|
511
|
+
* shares do I have, when does my token expire". */
|
|
512
|
+
export function meClient(workerUrl, token) {
|
|
513
|
+
const base = normServerUrl(workerUrl);
|
|
514
|
+
const call = async (method, path) => {
|
|
515
|
+
try {
|
|
516
|
+
const res = await fetch(`${base}${path}`, {
|
|
517
|
+
method,
|
|
518
|
+
headers: { authorization: `Bearer ${token}` },
|
|
519
|
+
signal: AbortSignal.timeout(12000),
|
|
520
|
+
});
|
|
521
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
522
|
+
if (!res.ok)
|
|
523
|
+
return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
|
|
524
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
|
|
525
|
+
}
|
|
526
|
+
catch (error) {
|
|
527
|
+
return { ok: false, status: 0, error: error.message };
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
me: () => call("GET", "/api/me"),
|
|
532
|
+
myShares: () => call("GET", "/api/me/shares"),
|
|
533
|
+
};
|
|
534
|
+
}
|
|
433
535
|
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
434
536
|
* legacy createToken (when it points at this server), or prompt. */
|
|
435
537
|
export function adminTokenFor(url, cfg = currentConfig()) {
|
|
@@ -452,11 +554,26 @@ export async function waitForBinding(client, sid, timeoutMs = 25000) {
|
|
|
452
554
|
return undefined;
|
|
453
555
|
}
|
|
454
556
|
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
455
|
-
* directive — no remembered consent, no silent creates).
|
|
456
|
-
|
|
557
|
+
* directive — no remembered consent, no silent creates). The dialog
|
|
558
|
+
* carries the live context (server, TTL, gate state) so the decision is
|
|
559
|
+
* informed, not ceremonial. */
|
|
560
|
+
export async function requestShareConsent(host, context) {
|
|
561
|
+
const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
|
|
562
|
+
if (context) {
|
|
563
|
+
const bits = [];
|
|
564
|
+
if (context.server)
|
|
565
|
+
bits.push(`server: ${context.server}`);
|
|
566
|
+
if (context.ttlHours !== undefined)
|
|
567
|
+
bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
|
|
568
|
+
if (context.redaction && context.redaction.hits > 0)
|
|
569
|
+
bits.push(`redaction: ${context.redaction.hits} hit(s)`);
|
|
570
|
+
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
571
|
+
lines.push(bits.join(" - "));
|
|
572
|
+
}
|
|
573
|
+
lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
|
|
457
574
|
return host.ui.showConfirm({
|
|
458
575
|
title: "Share Session",
|
|
459
|
-
message: "
|
|
576
|
+
message: lines.join("\n"),
|
|
460
577
|
confirmLabel: "Share",
|
|
461
578
|
});
|
|
462
579
|
}
|
|
@@ -467,18 +584,37 @@ export async function shareWithConsent(host, sessionID) {
|
|
|
467
584
|
if (!sessionID)
|
|
468
585
|
return { ok: false, message: "No active session to share." };
|
|
469
586
|
const cfg = currentConfig();
|
|
470
|
-
|
|
471
|
-
|
|
587
|
+
// engine-aware: plugin-options-layer config can target servers the file
|
|
588
|
+
// chain cannot see (TUI blindness would push onboarding at a configured user)
|
|
589
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
590
|
+
return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
|
|
472
591
|
}
|
|
473
592
|
const root = await resolveRoot(host.client, sessionID);
|
|
474
|
-
const
|
|
475
|
-
|
|
593
|
+
const all = await readBindings(host.client, root);
|
|
594
|
+
// fully opted-out (leave-as-is everywhere) is NOT live: consent again
|
|
595
|
+
// instead of silently copying a snapshot link
|
|
596
|
+
const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
|
|
597
|
+
if (live) {
|
|
476
598
|
// already sharing: copy the link
|
|
477
|
-
await host.clipboard.write(
|
|
478
|
-
return { ok: true, message: `Share URL copied to clipboard: ${
|
|
599
|
+
await host.clipboard.write(live.url);
|
|
600
|
+
return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
|
|
601
|
+
}
|
|
602
|
+
const opts = all[0]?.opts;
|
|
603
|
+
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
604
|
+
{
|
|
605
|
+
const stats = sessionStatsFor(root);
|
|
606
|
+
const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
|
|
607
|
+
if (!(await requestShareConsent(host, {
|
|
608
|
+
server: server ? hostOf(server.url) : undefined,
|
|
609
|
+
ttlHours: opts?.ttlHours ?? cfg.ttlHours,
|
|
610
|
+
auth: gated,
|
|
611
|
+
counts: { messages: stats.messages, toolCalls: stats.toolCalls },
|
|
612
|
+
// redaction only runs on ungated shares (a gated share is
|
|
613
|
+
// private from casual observers; the ledger reflects that)
|
|
614
|
+
redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) },
|
|
615
|
+
})))
|
|
616
|
+
return { ok: false, message: "Share cancelled." };
|
|
479
617
|
}
|
|
480
|
-
if (!(await requestShareConsent(host)))
|
|
481
|
-
return { ok: false, message: "Share cancelled." };
|
|
482
618
|
return confirmShare(host, root);
|
|
483
619
|
}
|
|
484
620
|
/** Post-consent share: set the flag, wait for the URL, copy it. */
|
|
@@ -488,12 +624,26 @@ export async function confirmShare(host, rootSessionID) {
|
|
|
488
624
|
dbgLog(`confirmShare: setShareFlag -> ${set}`);
|
|
489
625
|
if (!set)
|
|
490
626
|
return { ok: false, message: "Could not write the share flag into session metadata." };
|
|
627
|
+
// fresh consent: clear any leave-as-is markers so the engine re-arms
|
|
628
|
+
// (re-attaching the same pages wherever the secrets still exist)
|
|
629
|
+
await patchSessionBinding(host.client, rootSessionID, (servers) => {
|
|
630
|
+
for (const e of Object.values(servers)) {
|
|
631
|
+
if (e && typeof e === "object")
|
|
632
|
+
delete e.optedOut;
|
|
633
|
+
}
|
|
634
|
+
});
|
|
491
635
|
const binding = await waitForBinding(host.client, rootSessionID);
|
|
492
636
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
493
637
|
if (!binding?.url) {
|
|
638
|
+
// a failed create must NOT leave the flag armed: the engine would
|
|
639
|
+
// publish LATER with no further consent. Disarm now; re-running
|
|
640
|
+
// /share re-consents.
|
|
641
|
+
await clearShareFlag(host.client, rootSessionID);
|
|
642
|
+
const fresh = await readBinding(host.client, rootSessionID);
|
|
643
|
+
const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : "";
|
|
494
644
|
return {
|
|
495
645
|
ok: false,
|
|
496
|
-
message:
|
|
646
|
+
message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}\nCheck the [openshare] stderr lines for details.`,
|
|
497
647
|
};
|
|
498
648
|
}
|
|
499
649
|
await host.clipboard.write(binding.url);
|