@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/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/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
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
506
|
-
|
|
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
|
|
510
|
-
|
|
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(
|
|
513
|
-
return { ok: true, message: `Share URL copied to clipboard: ${
|
|
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
|
-
|
|
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)
|