@mirrowel/opencode-openshare 0.2.0-dev.2 → 0.2.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 +2 -0
- package/ci/README.md +15 -4
- package/ci/openshare.js +112 -26
- package/dist/commands.js +10 -7
- package/dist/config.d.ts +0 -1
- package/dist/config.js +19 -2
- package/dist/core.d.ts +1 -0
- package/dist/core.js +141 -28
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +104 -44
- package/dist/share-flow.d.ts +60 -4
- package/dist/share-flow.js +110 -15
- package/dist/tui.js +207 -65
- package/openshare.config.json +1 -2
- package/package.json +1 -1
- package/src/banner.txt +1 -1
- package/src/commands.ts +10 -7
- package/src/config.ts +17 -3
- package/src/core.ts +137 -29
- package/src/settings.ts +99 -37
- package/src/share-flow.ts +118 -17
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,6 +8,15 @@ 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;
|
|
@@ -55,8 +64,15 @@ export declare function resolveRoot(client: ClientApi | undefined, sid: string):
|
|
|
55
64
|
export declare function readBinding(client: ClientApi | undefined, sid: string): Promise<SessionBinding | undefined>;
|
|
56
65
|
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
57
66
|
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
58
|
-
* to manage; the flag just opts the session in.
|
|
67
|
+
* to manage; the flag just opts the session in. CONSENT ONLY: every call
|
|
68
|
+
* here means the user agreed to publish. */
|
|
59
69
|
export declare function setShareFlag(client: ClientApi | undefined, sid: string, opts?: SessionOpts): Promise<boolean>;
|
|
70
|
+
/** Persists per-session opts WITHOUT touching the enabled state. On an
|
|
71
|
+
* unshared session the opts sit dormant until a consented create consumes
|
|
72
|
+
* them; on a live share the engine picks them up and re-syncs the server
|
|
73
|
+
* (auth/TTL apply immediately). This must never publish anything - the
|
|
74
|
+
* TTL/auth editors use it, and consent is the only path to enabled. */
|
|
75
|
+
export declare function setShareOpts(client: ClientApi | undefined, sid: string, opts: SessionOpts): Promise<boolean>;
|
|
60
76
|
export declare function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean>;
|
|
61
77
|
export declare function workerDeleteShare(workerUrl: string, id: string, secret: string): Promise<boolean>;
|
|
62
78
|
/** Health probe for the settings dialog's connection test. */
|
|
@@ -268,6 +284,40 @@ export declare function adminClient(workerUrl: string, token: string): {
|
|
|
268
284
|
error?: string;
|
|
269
285
|
}>;
|
|
270
286
|
};
|
|
287
|
+
/** Self-service client for the /api/me surface (the entry's user osk_
|
|
288
|
+
* token - NOT the root token). Answers "what are my limits, how many
|
|
289
|
+
* shares do I have, when does my token expire". */
|
|
290
|
+
export declare function meClient(workerUrl: string, token: string): {
|
|
291
|
+
me: () => Promise<{
|
|
292
|
+
ok: boolean;
|
|
293
|
+
status: number;
|
|
294
|
+
data?: {
|
|
295
|
+
userId: string;
|
|
296
|
+
label: string;
|
|
297
|
+
token: {
|
|
298
|
+
id: string;
|
|
299
|
+
label: string;
|
|
300
|
+
limits: Record<string, number>;
|
|
301
|
+
expiresAt: number;
|
|
302
|
+
};
|
|
303
|
+
settings: Record<string, unknown>;
|
|
304
|
+
} | undefined;
|
|
305
|
+
error?: string;
|
|
306
|
+
}>;
|
|
307
|
+
myShares: () => Promise<{
|
|
308
|
+
ok: boolean;
|
|
309
|
+
status: number;
|
|
310
|
+
data?: {
|
|
311
|
+
shares: Array<{
|
|
312
|
+
id: string;
|
|
313
|
+
sessionID: string;
|
|
314
|
+
createdAt: number;
|
|
315
|
+
status: string;
|
|
316
|
+
}>;
|
|
317
|
+
} | undefined;
|
|
318
|
+
error?: string;
|
|
319
|
+
}>;
|
|
320
|
+
};
|
|
271
321
|
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
272
322
|
* legacy createToken (when it points at this server), or prompt. */
|
|
273
323
|
export declare function adminTokenFor(url: string, cfg?: ShareConfig): string;
|
|
@@ -281,8 +331,14 @@ export type FlowResult = {
|
|
|
281
331
|
unconfigured?: boolean;
|
|
282
332
|
};
|
|
283
333
|
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
284
|
-
* directive — no remembered consent, no silent creates).
|
|
285
|
-
|
|
334
|
+
* directive — no remembered consent, no silent creates). The dialog
|
|
335
|
+
* carries the live context (server, TTL, gate state) so the decision is
|
|
336
|
+
* informed, not ceremonial. */
|
|
337
|
+
export declare function requestShareConsent(host: ShareHostApi, context?: {
|
|
338
|
+
server?: string;
|
|
339
|
+
ttlDays?: number;
|
|
340
|
+
auth: boolean;
|
|
341
|
+
}): Promise<boolean>;
|
|
286
342
|
/** runShareFlow with the consent gate applied: share copies the link,
|
|
287
343
|
* unshared asks consent once, then creates. Every create path routes
|
|
288
344
|
* 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 } from "./core.js";
|
|
19
20
|
import { queueBindingWrite } from "./binding-queue.js";
|
|
20
21
|
export function pluginConfigPaths(baseDir = moduleDir()) {
|
|
21
22
|
return configPaths(baseDir);
|
|
@@ -53,7 +54,6 @@ export function saveConfig(cfg, target, baseDir = moduleDir()) {
|
|
|
53
54
|
"completedDelayMs",
|
|
54
55
|
"modelLabel",
|
|
55
56
|
"ttlDays",
|
|
56
|
-
"openTasksInNewTab",
|
|
57
57
|
"authLogin",
|
|
58
58
|
"authPassword",
|
|
59
59
|
"spoolDir",
|
|
@@ -88,8 +88,10 @@ export function serverEntries(cfg = currentConfig()) {
|
|
|
88
88
|
for (const [url, entry] of Object.entries(cfg.servers))
|
|
89
89
|
merged[url] = { ...merged[url], ...entry };
|
|
90
90
|
const urls = Object.keys(merged);
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// EVERY explicit active is active (the engine fans out to all of them);
|
|
92
|
+
// only when none is marked does the first entry act as the default
|
|
93
|
+
const anyActive = urls.some((u) => merged[u].active);
|
|
94
|
+
return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
|
|
93
95
|
}
|
|
94
96
|
export { saveServerEntry, deleteServerEntry };
|
|
95
97
|
// --- session metadata helpers -------------------------------------------------
|
|
@@ -101,6 +103,8 @@ export function bindingsOf(info) {
|
|
|
101
103
|
const record = raw;
|
|
102
104
|
const opts = record.opts ?? undefined;
|
|
103
105
|
const enabled = record.enabled === true ? true : undefined;
|
|
106
|
+
const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
|
|
107
|
+
const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
|
|
104
108
|
const out = [];
|
|
105
109
|
if (record.servers && typeof record.servers === "object") {
|
|
106
110
|
for (const [server, entry] of Object.entries(record.servers)) {
|
|
@@ -119,6 +123,9 @@ export function bindingsOf(info) {
|
|
|
119
123
|
opts,
|
|
120
124
|
server,
|
|
121
125
|
collided,
|
|
126
|
+
optedOut: e.optedOut === true ? true : undefined,
|
|
127
|
+
lastError,
|
|
128
|
+
ttlClamped,
|
|
122
129
|
});
|
|
123
130
|
}
|
|
124
131
|
return out;
|
|
@@ -135,6 +142,8 @@ export function bindingsOf(info) {
|
|
|
135
142
|
opts,
|
|
136
143
|
server: "",
|
|
137
144
|
collided,
|
|
145
|
+
lastError,
|
|
146
|
+
ttlClamped,
|
|
138
147
|
});
|
|
139
148
|
}
|
|
140
149
|
return out;
|
|
@@ -230,7 +239,8 @@ export async function readBinding(client, sid) {
|
|
|
230
239
|
}
|
|
231
240
|
/** Sets metadata.openshare = { ...existing(v2 structure preserved),
|
|
232
241
|
* enabled: true, opts? }. The per-server binding entries are the ENGINE's
|
|
233
|
-
* to manage; the flag just opts the session in.
|
|
242
|
+
* to manage; the flag just opts the session in. CONSENT ONLY: every call
|
|
243
|
+
* here means the user agreed to publish. */
|
|
234
244
|
export async function setShareFlag(client, sid, opts) {
|
|
235
245
|
const info = await getSession(client, sid);
|
|
236
246
|
if (!info)
|
|
@@ -246,6 +256,27 @@ export async function setShareFlag(client, sid, opts) {
|
|
|
246
256
|
metadata[METADATA_KEY] = value;
|
|
247
257
|
return patchMetadata(client, sid, metadata);
|
|
248
258
|
}
|
|
259
|
+
/** Persists per-session opts WITHOUT touching the enabled state. On an
|
|
260
|
+
* unshared session the opts sit dormant until a consented create consumes
|
|
261
|
+
* them; on a live share the engine picks them up and re-syncs the server
|
|
262
|
+
* (auth/TTL apply immediately). This must never publish anything - the
|
|
263
|
+
* TTL/auth editors use it, and consent is the only path to enabled. */
|
|
264
|
+
export async function setShareOpts(client, sid, opts) {
|
|
265
|
+
const info = await getSession(client, sid);
|
|
266
|
+
if (!info)
|
|
267
|
+
return false;
|
|
268
|
+
const metadata = { ...(info.metadata ?? {}) };
|
|
269
|
+
const existing = info.metadata?.[METADATA_KEY];
|
|
270
|
+
const value = existing && typeof existing === "object"
|
|
271
|
+
? { ...existing }
|
|
272
|
+
: { v: 2, servers: {} };
|
|
273
|
+
if (Object.keys(opts).length)
|
|
274
|
+
value.opts = opts;
|
|
275
|
+
else
|
|
276
|
+
delete value.opts;
|
|
277
|
+
metadata[METADATA_KEY] = value;
|
|
278
|
+
return patchMetadata(client, sid, metadata);
|
|
279
|
+
}
|
|
249
280
|
export async function clearShareFlag(client, sid) {
|
|
250
281
|
const info = await getSession(client, sid);
|
|
251
282
|
if (!info)
|
|
@@ -430,6 +461,32 @@ export function adminClient(workerUrl, token) {
|
|
|
430
461
|
health: () => call("GET", "/health"),
|
|
431
462
|
};
|
|
432
463
|
}
|
|
464
|
+
/** Self-service client for the /api/me surface (the entry's user osk_
|
|
465
|
+
* token - NOT the root token). Answers "what are my limits, how many
|
|
466
|
+
* shares do I have, when does my token expire". */
|
|
467
|
+
export function meClient(workerUrl, token) {
|
|
468
|
+
const base = normServerUrl(workerUrl);
|
|
469
|
+
const call = async (method, path) => {
|
|
470
|
+
try {
|
|
471
|
+
const res = await fetch(`${base}${path}`, {
|
|
472
|
+
method,
|
|
473
|
+
headers: { authorization: `Bearer ${token}` },
|
|
474
|
+
signal: AbortSignal.timeout(12000),
|
|
475
|
+
});
|
|
476
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
477
|
+
if (!res.ok)
|
|
478
|
+
return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
|
|
479
|
+
return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
return { ok: false, status: 0, error: error.message };
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
return {
|
|
486
|
+
me: () => call("GET", "/api/me"),
|
|
487
|
+
myShares: () => call("GET", "/api/me/shares"),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
433
490
|
/** Resolve the admin credential for a server: the entry's adminToken, the
|
|
434
491
|
* legacy createToken (when it points at this server), or prompt. */
|
|
435
492
|
export function adminTokenFor(url, cfg = currentConfig()) {
|
|
@@ -452,11 +509,24 @@ export async function waitForBinding(client, sid, timeoutMs = 25000) {
|
|
|
452
509
|
return undefined;
|
|
453
510
|
}
|
|
454
511
|
/** Consent gate: EVERY share creation asks explicitly (per owner
|
|
455
|
-
* directive — no remembered consent, no silent creates).
|
|
456
|
-
|
|
512
|
+
* directive — no remembered consent, no silent creates). The dialog
|
|
513
|
+
* carries the live context (server, TTL, gate state) so the decision is
|
|
514
|
+
* informed, not ceremonial. */
|
|
515
|
+
export async function requestShareConsent(host, context) {
|
|
516
|
+
const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
|
|
517
|
+
if (context) {
|
|
518
|
+
const bits = [];
|
|
519
|
+
if (context.server)
|
|
520
|
+
bits.push(`server: ${context.server}`);
|
|
521
|
+
if (context.ttlDays !== undefined)
|
|
522
|
+
bits.push(`TTL: ${context.ttlDays}d`);
|
|
523
|
+
bits.push(`read auth: ${context.auth ? "on" : "off"}`);
|
|
524
|
+
lines.push(bits.join(" - "));
|
|
525
|
+
}
|
|
526
|
+
lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
|
|
457
527
|
return host.ui.showConfirm({
|
|
458
528
|
title: "Share Session",
|
|
459
|
-
message: "
|
|
529
|
+
message: lines.join("\n"),
|
|
460
530
|
confirmLabel: "Share",
|
|
461
531
|
});
|
|
462
532
|
}
|
|
@@ -467,17 +537,28 @@ export async function shareWithConsent(host, sessionID) {
|
|
|
467
537
|
if (!sessionID)
|
|
468
538
|
return { ok: false, message: "No active session to share." };
|
|
469
539
|
const cfg = currentConfig();
|
|
470
|
-
|
|
471
|
-
|
|
540
|
+
// engine-aware: plugin-options-layer config can target servers the file
|
|
541
|
+
// chain cannot see (TUI blindness would push onboarding at a configured user)
|
|
542
|
+
if (!isConfigured(cfg) && !engineTargetUrls().length) {
|
|
543
|
+
return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
|
|
472
544
|
}
|
|
473
545
|
const root = await resolveRoot(host.client, sessionID);
|
|
474
|
-
const
|
|
475
|
-
|
|
546
|
+
const all = await readBindings(host.client, root);
|
|
547
|
+
// fully opted-out (leave-as-is everywhere) is NOT live: consent again
|
|
548
|
+
// instead of silently copying a snapshot link
|
|
549
|
+
const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
|
|
550
|
+
if (live) {
|
|
476
551
|
// already sharing: copy the link
|
|
477
|
-
await host.clipboard.write(
|
|
478
|
-
return { ok: true, message: `Share URL copied to clipboard: ${
|
|
552
|
+
await host.clipboard.write(live.url);
|
|
553
|
+
return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
|
|
479
554
|
}
|
|
480
|
-
|
|
555
|
+
const opts = all[0]?.opts;
|
|
556
|
+
const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
|
|
557
|
+
if (!(await requestShareConsent(host, {
|
|
558
|
+
server: server ? hostOf(server.url) : undefined,
|
|
559
|
+
ttlDays: opts?.ttlDays ?? cfg.ttlDays,
|
|
560
|
+
auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword),
|
|
561
|
+
})))
|
|
481
562
|
return { ok: false, message: "Share cancelled." };
|
|
482
563
|
return confirmShare(host, root);
|
|
483
564
|
}
|
|
@@ -488,12 +569,26 @@ export async function confirmShare(host, rootSessionID) {
|
|
|
488
569
|
dbgLog(`confirmShare: setShareFlag -> ${set}`);
|
|
489
570
|
if (!set)
|
|
490
571
|
return { ok: false, message: "Could not write the share flag into session metadata." };
|
|
572
|
+
// fresh consent: clear any leave-as-is markers so the engine re-arms
|
|
573
|
+
// (re-attaching the same pages wherever the secrets still exist)
|
|
574
|
+
await patchSessionBinding(host.client, rootSessionID, (servers) => {
|
|
575
|
+
for (const e of Object.values(servers)) {
|
|
576
|
+
if (e && typeof e === "object")
|
|
577
|
+
delete e.optedOut;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
491
580
|
const binding = await waitForBinding(host.client, rootSessionID);
|
|
492
581
|
dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
|
|
493
582
|
if (!binding?.url) {
|
|
583
|
+
// a failed create must NOT leave the flag armed - the engine would
|
|
584
|
+
// publish LATER with no further consent (live-caught). Disarm now;
|
|
585
|
+
// re-running /share re-consents.
|
|
586
|
+
await clearShareFlag(host.client, rootSessionID);
|
|
587
|
+
const fresh = await readBinding(host.client, rootSessionID);
|
|
588
|
+
const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : "";
|
|
494
589
|
return {
|
|
495
590
|
ok: false,
|
|
496
|
-
message:
|
|
591
|
+
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
592
|
};
|
|
498
593
|
}
|
|
499
594
|
await host.clipboard.write(binding.url);
|