@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.
@@ -1,5 +1,5 @@
1
1
  import { saveServerEntry, deleteServerEntry, type ShareConfig, type ServerEntry } from "./config.js";
2
- import type { ClientApi, SessionInfo, SessionOpts } from "./core.js";
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
- export declare function requestShareConsent(host: ShareHostApi): Promise<boolean>;
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). */
@@ -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
- const activeUrl = urls.find((u) => merged[u].active) ?? urls[0];
92
- return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }));
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
- export async function requestShareConsent(host) {
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: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
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
- if (!isConfigured(cfg)) {
471
- return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true };
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 existing = await readBinding(host.client, root);
475
- if (existing?.url && existing.enabled !== false) {
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(existing.url);
478
- return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url };
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
- if (!(await requestShareConsent(host)))
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: "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
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);