@mirrowel/opencode-openshare 0.2.0 → 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/src/share-flow.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import { existsSync, readFileSync, writeFileSync } from "node:fs"
18
18
  import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY, type ShareConfig, type ServerEntry } from "./config.js"
19
19
  import { dbg, dbgLog } from "./sidebar.js"
20
- import type { ClientApi, SessionInfo, SessionOpts } from "./core.js"
20
+ import { engineTargetUrls, type ClientApi, type SessionInfo, type SessionOpts } from "./core.js"
21
21
  import { queueBindingWrite } from "./binding-queue.js"
22
22
  import type { ShareHostApi } from "./tui-host.js"
23
23
 
@@ -28,6 +28,12 @@ export type SessionBinding = {
28
28
  url: string
29
29
  enabled?: boolean
30
30
  opts?: SessionOpts
31
+ /** per-server "leave as-is" marker: a snapshot, not a live mirror */
32
+ optedOut?: boolean
33
+ /** engine-recorded last create/attach refusal (quota, frozen, ...) */
34
+ lastError?: string
35
+ /** server policy clamped the requested TTL */
36
+ ttlClamped?: { requested: number; effective: number }
31
37
  }
32
38
 
33
39
  export function pluginConfigPaths(baseDir: string = moduleDir()): string[] {
@@ -66,7 +72,6 @@ export function saveConfig(cfg: ShareConfig, target?: string, baseDir: string =
66
72
  "completedDelayMs",
67
73
  "modelLabel",
68
74
  "ttlDays",
69
- "openTasksInNewTab",
70
75
  "authLogin",
71
76
  "authPassword",
72
77
  "spoolDir",
@@ -100,8 +105,10 @@ export function serverEntries(cfg: ShareConfig = currentConfig()): Array<{ url:
100
105
  if (cfg.workerUrl) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
101
106
  if (cfg.servers) for (const [url, entry] of Object.entries(cfg.servers)) merged[url] = { ...merged[url], ...entry }
102
107
  const urls = Object.keys(merged)
103
- const activeUrl = urls.find((u) => merged[u].active) ?? urls[0]
104
- return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }))
108
+ // EVERY explicit active is active (the engine fans out to all of them);
109
+ // only when none is marked does the first entry act as the default
110
+ const anyActive = urls.some((u) => merged[u].active)
111
+ return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }))
105
112
  }
106
113
 
107
114
  export { saveServerEntry, deleteServerEntry }
@@ -115,6 +122,8 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
115
122
  const record = raw as Record<string, unknown>
116
123
  const opts = (record.opts as SessionOpts | undefined) ?? undefined
117
124
  const enabled = record.enabled === true ? true : undefined
125
+ const lastError = typeof record.lastError === "string" ? record.lastError : undefined
126
+ const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? (record.ttlClamped as { requested: number; effective: number }) : undefined
118
127
  const out: Array<SessionBinding & { server: string; collided?: string }> = []
119
128
  if (record.servers && typeof record.servers === "object") {
120
129
  for (const [server, entry] of Object.entries(record.servers as Record<string, unknown>)) {
@@ -131,6 +140,9 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
131
140
  opts,
132
141
  server,
133
142
  collided,
143
+ optedOut: e.optedOut === true ? true : undefined,
144
+ lastError,
145
+ ttlClamped,
134
146
  })
135
147
  }
136
148
  return out
@@ -147,6 +159,8 @@ export function bindingsOf(info: SessionInfo | undefined): Array<SessionBinding
147
159
  opts,
148
160
  server: "",
149
161
  collided,
162
+ lastError,
163
+ ttlClamped,
150
164
  })
151
165
  }
152
166
  return out
@@ -250,7 +264,8 @@ export async function readBinding(client: ClientApi | undefined, sid: string): P
250
264
 
251
265
  /** Sets metadata.openshare = { ...existing(v2 structure preserved),
252
266
  * enabled: true, opts? }. The per-server binding entries are the ENGINE's
253
- * to manage; the flag just opts the session in. */
267
+ * to manage; the flag just opts the session in. CONSENT ONLY: every call
268
+ * here means the user agreed to publish. */
254
269
  export async function setShareFlag(
255
270
  client: ClientApi | undefined,
256
271
  sid: string,
@@ -270,6 +285,30 @@ export async function setShareFlag(
270
285
  return patchMetadata(client, sid, metadata)
271
286
  }
272
287
 
288
+ /** Persists per-session opts WITHOUT touching the enabled state. On an
289
+ * unshared session the opts sit dormant until a consented create consumes
290
+ * them; on a live share the engine picks them up and re-syncs the server
291
+ * (auth/TTL apply immediately). This must never publish anything - the
292
+ * TTL/auth editors use it, and consent is the only path to enabled. */
293
+ export async function setShareOpts(
294
+ client: ClientApi | undefined,
295
+ sid: string,
296
+ opts: SessionOpts,
297
+ ): Promise<boolean> {
298
+ const info = await getSession(client, sid)
299
+ if (!info) return false
300
+ const metadata: Record<string, unknown> = { ...(info.metadata ?? {}) }
301
+ const existing = info.metadata?.[METADATA_KEY]
302
+ const value: Record<string, unknown> =
303
+ existing && typeof existing === "object"
304
+ ? { ...(existing as Record<string, unknown>) }
305
+ : { v: 2, servers: {} }
306
+ if (Object.keys(opts).length) value.opts = opts
307
+ else delete value.opts
308
+ metadata[METADATA_KEY] = value
309
+ return patchMetadata(client, sid, metadata)
310
+ }
311
+
273
312
  export async function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean> {
274
313
  const info = await getSession(client, sid)
275
314
  if (!info) return false
@@ -456,6 +495,31 @@ export function adminClient(workerUrl: string, token: string) {
456
495
  }
457
496
  }
458
497
 
498
+ /** Self-service client for the /api/me surface (the entry's user osk_
499
+ * token - NOT the root token). Answers "what are my limits, how many
500
+ * shares do I have, when does my token expire". */
501
+ export function meClient(workerUrl: string, token: string) {
502
+ const base = normServerUrl(workerUrl)
503
+ const call = async <T>(method: string, path: string): Promise<{ ok: boolean; status: number; data?: T; error?: string }> => {
504
+ try {
505
+ const res = await fetch(`${base}${path}`, {
506
+ method,
507
+ headers: { authorization: `Bearer ${token}` },
508
+ signal: AbortSignal.timeout(12000),
509
+ })
510
+ const parsed = (await res.json().catch(() => ({}))) as T & { ok?: boolean; error?: string }
511
+ if (!res.ok) return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` }
512
+ return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error }
513
+ } catch (error) {
514
+ return { ok: false, status: 0, error: (error as Error).message }
515
+ }
516
+ }
517
+ return {
518
+ me: () => call<{ userId: string; label: string; token: { id: string; label: string; limits: Record<string, number>; expiresAt: number }; settings: Record<string, unknown> }>("GET", "/api/me"),
519
+ myShares: () => call<{ shares: Array<{ id: string; sessionID: string; createdAt: number; status: string }> }>("GET", "/api/me/shares"),
520
+ }
521
+ }
522
+
459
523
  /** Resolve the admin credential for a server: the entry's adminToken, the
460
524
  * legacy createToken (when it points at this server), or prompt. */
461
525
  export function adminTokenFor(url: string, cfg: ShareConfig = currentConfig()): string {
@@ -487,11 +551,22 @@ export async function waitForBinding(
487
551
  export type FlowResult = { ok: boolean; message: string; url?: string; unconfigured?: boolean }
488
552
 
489
553
  /** Consent gate: EVERY share creation asks explicitly (per owner
490
- * directive — no remembered consent, no silent creates). */
491
- export async function requestShareConsent(host: ShareHostApi): Promise<boolean> {
554
+ * directive — no remembered consent, no silent creates). The dialog
555
+ * carries the live context (server, TTL, gate state) so the decision is
556
+ * informed, not ceremonial. */
557
+ export async function requestShareConsent(host: ShareHostApi, context?: { server?: string; ttlDays?: number; auth: boolean }): Promise<boolean> {
558
+ const lines: string[] = ["Publish this session (and its subagent sessions) to your openshare worker?"]
559
+ if (context) {
560
+ const bits: string[] = []
561
+ if (context.server) bits.push(`server: ${context.server}`)
562
+ if (context.ttlDays !== undefined) bits.push(`TTL: ${context.ttlDays}d`)
563
+ bits.push(`read auth: ${context.auth ? "on" : "off"}`)
564
+ lines.push(bits.join(" - "))
565
+ }
566
+ lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).")
492
567
  return host.ui.showConfirm({
493
568
  title: "Share Session",
494
- message: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
569
+ message: lines.join("\n"),
495
570
  confirmLabel: "Share",
496
571
  })
497
572
  }
@@ -502,17 +577,31 @@ export async function requestShareConsent(host: ShareHostApi): Promise<boolean>
502
577
  export async function shareWithConsent(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult> {
503
578
  if (!sessionID) return { ok: false, message: "No active session to share." }
504
579
  const cfg = currentConfig()
505
- if (!isConfigured(cfg)) {
506
- return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true }
580
+ // engine-aware: plugin-options-layer config can target servers the file
581
+ // chain cannot see (TUI blindness would push onboarding at a configured user)
582
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
583
+ return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true }
507
584
  }
508
585
  const root = await resolveRoot(host.client, sessionID)
509
- const existing = await readBinding(host.client, root)
510
- if (existing?.url && existing.enabled !== false) {
586
+ const all = await readBindings(host.client, root)
587
+ // fully opted-out (leave-as-is everywhere) is NOT live: consent again
588
+ // instead of silently copying a snapshot link
589
+ const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut)
590
+ if (live) {
511
591
  // already sharing: copy the link
512
- await host.clipboard.write(existing.url)
513
- return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url }
592
+ await host.clipboard.write(live.url)
593
+ return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url }
514
594
  }
515
- if (!(await requestShareConsent(host))) return { ok: false, message: "Share cancelled." }
595
+ const opts = all[0]?.opts
596
+ const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined)
597
+ if (
598
+ !(await requestShareConsent(host, {
599
+ server: server ? hostOf(server.url) : undefined,
600
+ ttlDays: opts?.ttlDays ?? cfg.ttlDays,
601
+ auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword),
602
+ }))
603
+ )
604
+ return { ok: false, message: "Share cancelled." }
516
605
  return confirmShare(host, root)
517
606
  }
518
607
 
@@ -522,13 +611,25 @@ export async function confirmShare(host: ShareHostApi, rootSessionID: string): P
522
611
  const set = await setShareFlag(host.client, rootSessionID)
523
612
  dbgLog(`confirmShare: setShareFlag -> ${set}`)
524
613
  if (!set) return { ok: false, message: "Could not write the share flag into session metadata." }
614
+ // fresh consent: clear any leave-as-is markers so the engine re-arms
615
+ // (re-attaching the same pages wherever the secrets still exist)
616
+ await patchSessionBinding(host.client, rootSessionID, (servers) => {
617
+ for (const e of Object.values(servers)) {
618
+ if (e && typeof e === "object") delete (e as Record<string, unknown>).optedOut
619
+ }
620
+ })
525
621
  const binding = await waitForBinding(host.client, rootSessionID)
526
622
  dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`)
527
623
  if (!binding?.url) {
624
+ // a failed create must NOT leave the flag armed - the engine would
625
+ // publish LATER with no further consent (live-caught). Disarm now;
626
+ // re-running /share re-consents.
627
+ await clearShareFlag(host.client, rootSessionID)
628
+ const fresh = await readBinding(host.client, rootSessionID)
629
+ const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : ""
528
630
  return {
529
631
  ok: false,
530
- message:
531
- "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
632
+ 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.`,
532
633
  }
533
634
  }
534
635
  await host.clipboard.write(binding.url)