opencode-session-port 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +84 -2
- package/dist/tui.js +10276 -0
- package/package.json +48 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- package/tui.ts +17 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort TUI command composition (Phase 2 experimental Windows wiring).
|
|
3
|
+
*
|
|
4
|
+
* `createSessionPortHandlers` composes the completed Phase 2-4 seams into the
|
|
5
|
+
* two injected command callbacks consumed by `installSessionPortCommands`:
|
|
6
|
+
*
|
|
7
|
+
* `/checkpoint [artifact-path]`
|
|
8
|
+
* 1. resolve the active project from `context.location.directory` through the
|
|
9
|
+
* public `location.get` resolver (exact response matching; fail closed);
|
|
10
|
+
* 2. `runCheckpointFlow` — exhaustive project-scoped collection (fail, never
|
|
11
|
+
* truncate, on any collection cap), matching password prompt, P-15 path
|
|
12
|
+
* resolution, seal, exclusive encrypted write;
|
|
13
|
+
* 3. report path/size/session-count facts or a sanitized failure.
|
|
14
|
+
*
|
|
15
|
+
* `/restore <artifact-path>`
|
|
16
|
+
* 1. require the artifact path argument (nothing runs without it);
|
|
17
|
+
* 2. probe `session.import` capability FIRST — before any artifact read,
|
|
18
|
+
* decrypt, or import (O-12);
|
|
19
|
+
* 3. enumerate existing host projects via `project.list`, let the user select
|
|
20
|
+
* with `selectRestoreProject`;
|
|
21
|
+
* 4. call `location.get` ONLY for the selected candidate and require an exact
|
|
22
|
+
* project-ID/directory match (E42 side effect; OQ-16 stays OPEN);
|
|
23
|
+
* 5. `runRestorePreflightWithPasswordAttempts` — up to three password
|
|
24
|
+
* attempts, full authentication, strict parse, destination snapshot, the
|
|
25
|
+
* required metadata-only cross-project session-ID occupancy inventory
|
|
26
|
+
* (reusing the already-validated selection candidates; no second
|
|
27
|
+
* `project.list`, no `location.get`, no message reads), and `planRestore`
|
|
28
|
+
* before anything else happens;
|
|
29
|
+
* 6. `runRestoreExecution` — sequential parent-first imports with the
|
|
30
|
+
* explicit target on every call, no retries, no rollback promise;
|
|
31
|
+
* 7. present the completed non-empty result through the designer-owned
|
|
32
|
+
* keyboard-navigable report dialog (aggregate/per-session facts only); an
|
|
33
|
+
* empty result keeps the truthful non-success notification, and a
|
|
34
|
+
* dialog-display failure falls back to bounded aggregate-only copy.
|
|
35
|
+
*
|
|
36
|
+
* Hard boundaries: no shell/Git, no LLM/model call, no historical tool replay,
|
|
37
|
+
* no direct database access, no `project.update`; passwords stay masked and are
|
|
38
|
+
* owned/zeroed by the orchestration helpers; only already-sealed ciphertext is
|
|
39
|
+
* written and only encrypted artifacts are read. Everything is injected through
|
|
40
|
+
* `SessionPortHandlerDependencies`, so tests use fake clients, fake crypto,
|
|
41
|
+
* fake artifact I/O, fake dialogs, and a fake report presenter only.
|
|
42
|
+
*
|
|
43
|
+
* Platform policy: registration is gated in `install.ts`; this module itself
|
|
44
|
+
* makes no platform-support claim.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { ByteSource, CheckpointCryptoFactory } from "../../core/contracts/index.ts";
|
|
48
|
+
import { collectCheckpointPayload } from "../../core/sessions/index.ts";
|
|
49
|
+
import type { RemappedIdDeriver } from "../../core/restore/index.ts";
|
|
50
|
+
import type { ResolveCheckpointOutputPathOptions } from "../artifacts/files.ts";
|
|
51
|
+
import { createLocationResolver, LocationResolutionError } from "../adapters/location-resolver.ts";
|
|
52
|
+
import { createProjectDestinationsAdapter } from "../adapters/project-destinations.ts";
|
|
53
|
+
import { createSessionIdInventory } from "../adapters/session-id-inventory.ts";
|
|
54
|
+
import { createSessionReadAdapter } from "../adapters/session-read.ts";
|
|
55
|
+
import { createSessionImportAdapter, probeSessionImportCapability } from "../adapters/session-import.ts";
|
|
56
|
+
import {
|
|
57
|
+
runCheckpointFlow,
|
|
58
|
+
runRestoreExecution,
|
|
59
|
+
runRestorePreflightWithPasswordAttempts,
|
|
60
|
+
type RestoreExecutionResult,
|
|
61
|
+
} from "../orchestration/index.ts";
|
|
62
|
+
import { requireCallable, isRecordValue } from "../orchestration/shared.ts";
|
|
63
|
+
import { selectRestoreProject, type RestoreProjectDialog } from "../ui/restore-project-selector.ts";
|
|
64
|
+
import type { RestoreReportPresentationModel } from "../ui/restore-report-flow.ts";
|
|
65
|
+
import type { SessionPortCommandHandlers } from "../ui/sessionport-commands.tsx";
|
|
66
|
+
import {
|
|
67
|
+
EXPERIMENTAL_COLLECTION_POLICY,
|
|
68
|
+
EXPERIMENTAL_CRYPTO_POLICY,
|
|
69
|
+
EXPERIMENTAL_MAX_PASSWORD_BYTES,
|
|
70
|
+
} from "./experimental-profile.ts";
|
|
71
|
+
import {
|
|
72
|
+
mapRestoreExecutionToReportPresentation,
|
|
73
|
+
reportCheckpointFailure,
|
|
74
|
+
reportCheckpointOutcome,
|
|
75
|
+
reportRestoreAuthenticationFailed,
|
|
76
|
+
reportRestoreCanceled,
|
|
77
|
+
reportRestoreCapabilityUnavailable,
|
|
78
|
+
reportRestoreExecution,
|
|
79
|
+
reportRestoreExecutionFallback,
|
|
80
|
+
reportRestoreFailure,
|
|
81
|
+
reportRestoreUsage,
|
|
82
|
+
type SessionPortNotification,
|
|
83
|
+
} from "./reports.ts";
|
|
84
|
+
|
|
85
|
+
/* ------------------------------------------------------------------------- */
|
|
86
|
+
/* Injected dependency surface */
|
|
87
|
+
/* ------------------------------------------------------------------------- */
|
|
88
|
+
|
|
89
|
+
/** One notification sink; the install layer maps it to `context.ui.toast`. */
|
|
90
|
+
export type SessionPortNotifier = (notification: SessionPortNotification) => void;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Masked password provider: one call per prompt, resolves with the UTF-8 bytes
|
|
94
|
+
* or `undefined` on cancel. The experimental 4-KiB byte limit is always passed.
|
|
95
|
+
*/
|
|
96
|
+
export type SessionPortPasswordProvider = (
|
|
97
|
+
mode: "checkpoint" | "restore",
|
|
98
|
+
maxPasswordBytes: number,
|
|
99
|
+
) => Promise<Uint8Array | undefined>;
|
|
100
|
+
|
|
101
|
+
/** Explicit, fully injected command dependencies. Every effect is supplied by the caller. */
|
|
102
|
+
export interface SessionPortHandlerDependencies {
|
|
103
|
+
/** Host-provided public client (untrusted); all host methods go through the existing adapters. */
|
|
104
|
+
readonly client: unknown;
|
|
105
|
+
/** Live read of the active project directory (`context.location?.directory`). */
|
|
106
|
+
readonly getActiveDirectory: () => string | undefined;
|
|
107
|
+
/** Host dialog selection surface for the restore destination. */
|
|
108
|
+
readonly dialog: RestoreProjectDialog;
|
|
109
|
+
/** Sanitized notification sink. */
|
|
110
|
+
readonly notify: SessionPortNotifier;
|
|
111
|
+
/** Presents the completed restore report (designer-owned dialog in production). */
|
|
112
|
+
readonly showRestoreReport: (model: RestoreReportPresentationModel) => void;
|
|
113
|
+
/** Masked password provider (real UI or a fake in tests). */
|
|
114
|
+
readonly requestPassword: SessionPortPasswordProvider;
|
|
115
|
+
/** Initialized crypto factory; production uses the experimental Windows profile. */
|
|
116
|
+
readonly cryptoFactory: CheckpointCryptoFactory;
|
|
117
|
+
/** Remapped-ID deriver for restore planning. */
|
|
118
|
+
readonly idDeriver: RemappedIdDeriver;
|
|
119
|
+
/** P-15 artifact output-path resolver. */
|
|
120
|
+
readonly resolveCheckpointOutputPath: (options: ResolveCheckpointOutputPathOptions) => Promise<string>;
|
|
121
|
+
/** Exclusive encrypted-artifact writer (ciphertext only). */
|
|
122
|
+
readonly writeEncryptedArtifact: (targetPath: string, source: ByteSource) => Promise<number>;
|
|
123
|
+
/** Lazy encrypted-artifact reader (no IO until iterated). */
|
|
124
|
+
readonly readEncryptedArtifact: (targetPath: string) => ByteSource;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------------------- */
|
|
128
|
+
/* createSessionPortHandlers */
|
|
129
|
+
/* ------------------------------------------------------------------------- */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Builds the two command callbacks. Dependency shape is validated fail-closed
|
|
133
|
+
* before any handler runs; host/API and orchestration errors are converted to
|
|
134
|
+
* sanitized reports inside each handler and are never rethrown to the host.
|
|
135
|
+
*/
|
|
136
|
+
export function createSessionPortHandlers(dependencies: SessionPortHandlerDependencies): SessionPortCommandHandlers {
|
|
137
|
+
if (!isRecordValue(dependencies)) {
|
|
138
|
+
throw new TypeError("createSessionPortHandlers requires an explicit dependency object");
|
|
139
|
+
}
|
|
140
|
+
requireCallable(dependencies.getActiveDirectory, "getActiveDirectory");
|
|
141
|
+
requireCallable(dependencies.notify, "notify");
|
|
142
|
+
requireCallable(dependencies.showRestoreReport, "showRestoreReport");
|
|
143
|
+
requireCallable(dependencies.requestPassword, "requestPassword");
|
|
144
|
+
requireCallable(dependencies.resolveCheckpointOutputPath, "resolveCheckpointOutputPath");
|
|
145
|
+
requireCallable(dependencies.writeEncryptedArtifact, "writeEncryptedArtifact");
|
|
146
|
+
requireCallable(dependencies.readEncryptedArtifact, "readEncryptedArtifact");
|
|
147
|
+
if (!isRecordValue(dependencies.cryptoFactory) || typeof dependencies.cryptoFactory.create !== "function") {
|
|
148
|
+
throw new TypeError("createSessionPortHandlers requires a CheckpointCryptoFactory with a create(policy) function");
|
|
149
|
+
}
|
|
150
|
+
if (!isRecordValue(dependencies.idDeriver) || typeof dependencies.idDeriver.derive !== "function") {
|
|
151
|
+
throw new TypeError("createSessionPortHandlers requires a RemappedIdDeriver with a derive(namespace, identity, id) function");
|
|
152
|
+
}
|
|
153
|
+
if (!isRecordValue(dependencies.dialog) || typeof dependencies.dialog.select !== "function") {
|
|
154
|
+
throw new TypeError("createSessionPortHandlers requires a dialog with a select(options) function");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
async checkpoint(rawInput: string | undefined): Promise<void> {
|
|
159
|
+
await runCheckpointCommand(dependencies, rawInput);
|
|
160
|
+
},
|
|
161
|
+
async restore(rawInput: string | undefined): Promise<void> {
|
|
162
|
+
await runRestoreCommand(dependencies, rawInput);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------------- */
|
|
168
|
+
/* /checkpoint */
|
|
169
|
+
/* ------------------------------------------------------------------------- */
|
|
170
|
+
|
|
171
|
+
async function runCheckpointCommand(
|
|
172
|
+
dependencies: SessionPortHandlerDependencies,
|
|
173
|
+
rawInput: string | undefined,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const explicitOutputPath = parseCheckpointOutputArgument(rawInput);
|
|
176
|
+
try {
|
|
177
|
+
const activeDirectory = dependencies.getActiveDirectory();
|
|
178
|
+
if (typeof activeDirectory !== "string" || activeDirectory.trim().length === 0) {
|
|
179
|
+
throw new LocationResolutionError(
|
|
180
|
+
"invalid-request",
|
|
181
|
+
"The active project directory is unavailable; checkpoint requires an active project",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
// One explicit location.get for the active directory (E42 side effect).
|
|
185
|
+
const resolved = await createLocationResolver(dependencies.client).resolveActiveProject(activeDirectory);
|
|
186
|
+
const readPort = createSessionReadAdapter(dependencies.client);
|
|
187
|
+
const outcome = await runCheckpointFlow(readPort, {
|
|
188
|
+
projectID: resolved.projectID,
|
|
189
|
+
projectDirectory: resolved.directory,
|
|
190
|
+
policy: EXPERIMENTAL_COLLECTION_POLICY,
|
|
191
|
+
cryptoFactory: dependencies.cryptoFactory,
|
|
192
|
+
cryptoPolicy: EXPERIMENTAL_CRYPTO_POLICY,
|
|
193
|
+
resolveCheckpointOutputPath: dependencies.resolveCheckpointOutputPath,
|
|
194
|
+
writeEncryptedArtifact: dependencies.writeEncryptedArtifact,
|
|
195
|
+
requestPassword: (purpose) => dependencies.requestPassword(purpose, EXPERIMENTAL_MAX_PASSWORD_BYTES),
|
|
196
|
+
...(explicitOutputPath !== undefined ? { explicitOutputPath } : {}),
|
|
197
|
+
});
|
|
198
|
+
dependencies.notify(reportCheckpointOutcome(outcome));
|
|
199
|
+
} catch (error) {
|
|
200
|
+
dependencies.notify(reportCheckpointFailure(error));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ------------------------------------------------------------------------- */
|
|
205
|
+
/* /restore */
|
|
206
|
+
/* ------------------------------------------------------------------------- */
|
|
207
|
+
|
|
208
|
+
async function runRestoreCommand(
|
|
209
|
+
dependencies: SessionPortHandlerDependencies,
|
|
210
|
+
rawInput: string | undefined,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const artifactPath = parseRestoreArtifactArgument(rawInput);
|
|
213
|
+
if (artifactPath === undefined) {
|
|
214
|
+
dependencies.notify(reportRestoreUsage());
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// O-12: capability absence is the first check — before any file read,
|
|
219
|
+
// decrypt, destination enumeration, or import.
|
|
220
|
+
const capability = probeSessionImportCapability(dependencies.client);
|
|
221
|
+
if (!capability.available) {
|
|
222
|
+
dependencies.notify(reportRestoreCapabilityUnavailable());
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let executionStarted = false;
|
|
227
|
+
try {
|
|
228
|
+
// Candidate enumeration and user selection happen before any location.get
|
|
229
|
+
// or artifact access; a canceled selection changes nothing.
|
|
230
|
+
const destinations = await createProjectDestinationsAdapter(dependencies.client).listDestinations();
|
|
231
|
+
const selected = await selectRestoreProject(dependencies.dialog, destinations);
|
|
232
|
+
if (selected === undefined) {
|
|
233
|
+
dependencies.notify(reportRestoreCanceled());
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// location.get ONLY for the selected candidate; exact ID/directory match
|
|
238
|
+
// is required before any artifact read/decrypt/import (E42; OQ-16 OPEN).
|
|
239
|
+
const resolved = await createLocationResolver(dependencies.client).resolveSelectedProject({
|
|
240
|
+
projectID: selected.projectID,
|
|
241
|
+
targetDirectory: selected.targetDirectory,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Destination snapshot uses the same explicit project scope and the
|
|
245
|
+
// experimental collection bounds; it is read by runRestorePreflight only
|
|
246
|
+
// after full authentication and strict parsing. The cross-project
|
|
247
|
+
// session-ID inventory reuses the already-validated selection candidates
|
|
248
|
+
// (no second project.list) and scans only session metadata (no message
|
|
249
|
+
// reads, no location.get, no import).
|
|
250
|
+
const readPort = createSessionReadAdapter(dependencies.client);
|
|
251
|
+
const sessionIdInventory = createSessionIdInventory({
|
|
252
|
+
readPort,
|
|
253
|
+
projects: destinations,
|
|
254
|
+
selectedProjectID: resolved.projectID,
|
|
255
|
+
policy: EXPERIMENTAL_COLLECTION_POLICY,
|
|
256
|
+
});
|
|
257
|
+
const outcome = await runRestorePreflightWithPasswordAttempts(dependencies.client, {
|
|
258
|
+
artifactPath,
|
|
259
|
+
destinationProjectID: resolved.projectID,
|
|
260
|
+
targetDirectory: resolved.directory,
|
|
261
|
+
cryptoFactory: dependencies.cryptoFactory,
|
|
262
|
+
cryptoPolicy: EXPERIMENTAL_CRYPTO_POLICY,
|
|
263
|
+
readEncryptedArtifact: dependencies.readEncryptedArtifact,
|
|
264
|
+
requestPassword: (purpose) => dependencies.requestPassword(purpose, EXPERIMENTAL_MAX_PASSWORD_BYTES),
|
|
265
|
+
readDestination: async (projectID) =>
|
|
266
|
+
(await collectCheckpointPayload(readPort, { projectID, policy: EXPERIMENTAL_COLLECTION_POLICY })).sessions,
|
|
267
|
+
readGloballyVisibleSessionIDs: () => sessionIdInventory.readGloballyVisibleSessionIDs(),
|
|
268
|
+
idDeriver: dependencies.idDeriver,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (outcome.status === "capability-unavailable") {
|
|
272
|
+
dependencies.notify(reportRestoreCapabilityUnavailable());
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (outcome.status === "canceled") {
|
|
276
|
+
dependencies.notify(reportRestoreCanceled());
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (outcome.status === "authentication-failed") {
|
|
280
|
+
dependencies.notify(reportRestoreAuthenticationFailed());
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Only a completed preflight reaches execution; every import carries the
|
|
285
|
+
// explicit target directory through the existing adapter (O-16).
|
|
286
|
+
const importer = createSessionImportAdapter(dependencies.client);
|
|
287
|
+
executionStarted = true;
|
|
288
|
+
const result = await runRestoreExecution({
|
|
289
|
+
plan: outcome.plan,
|
|
290
|
+
importer,
|
|
291
|
+
targetDirectory: outcome.targetDirectory,
|
|
292
|
+
});
|
|
293
|
+
reportRestoreOutcome(dependencies, result);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
dependencies.notify(reportRestoreFailure(error, executionStarted));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ------------------------------------------------------------------------- */
|
|
300
|
+
/* Restore outcome presentation */
|
|
301
|
+
/* ------------------------------------------------------------------------- */
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Presents one completed restore execution. A non-empty result is mapped to the
|
|
305
|
+
* designer's `RestoreReportPresentationModel` and shown in the keyboard-
|
|
306
|
+
* navigable report dialog (up to the experimental session cap of outcomes, one
|
|
307
|
+
* navigable entry each) instead of a per-session toast. An empty result keeps
|
|
308
|
+
* the truthful non-success notification.
|
|
309
|
+
*
|
|
310
|
+
* If the report dialog (or the mapping into its model) throws after execution,
|
|
311
|
+
* the execution is still reported as finished through bounded aggregate-only
|
|
312
|
+
* copy: a display failure is never reported as an execution failure, and no raw
|
|
313
|
+
* exception text or per-session content reaches the notification.
|
|
314
|
+
*/
|
|
315
|
+
function reportRestoreOutcome(dependencies: SessionPortHandlerDependencies, result: RestoreExecutionResult): void {
|
|
316
|
+
if (result.summary.total === 0) {
|
|
317
|
+
dependencies.notify(reportRestoreExecution(result));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
dependencies.showRestoreReport(mapRestoreExecutionToReportPresentation(result));
|
|
322
|
+
} catch {
|
|
323
|
+
dependencies.notify(reportRestoreExecutionFallback(result));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* ------------------------------------------------------------------------- */
|
|
328
|
+
/* Raw-argument parsing (no flag syntax; no trimming of path values) */
|
|
329
|
+
/* ------------------------------------------------------------------------- */
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `/checkpoint` accepts one optional raw argument: the explicit artifact output
|
|
333
|
+
* path. `undefined` or an all-whitespace argument uses the P-15 default
|
|
334
|
+
* `.sessionport/` location; any other argument is trimmed of surrounding
|
|
335
|
+
* whitespace and passed to the P-15 resolver verbatim (no flag parsing, no
|
|
336
|
+
* shell expansion, no quote handling).
|
|
337
|
+
*/
|
|
338
|
+
export function parseCheckpointOutputArgument(rawInput: string | undefined): string | undefined {
|
|
339
|
+
if (rawInput === undefined) return undefined;
|
|
340
|
+
const trimmed = rawInput.trim();
|
|
341
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* `/restore` requires one raw argument: the artifact path. `undefined` or an
|
|
346
|
+
* all-whitespace argument yields `undefined` so the caller reports usage
|
|
347
|
+
* without touching the host; any other argument is trimmed of surrounding
|
|
348
|
+
* whitespace and used verbatim.
|
|
349
|
+
*/
|
|
350
|
+
export function parseRestoreArtifactArgument(rawInput: string | undefined): string | undefined {
|
|
351
|
+
if (rawInput === undefined) return undefined;
|
|
352
|
+
const trimmed = rawInput.trim();
|
|
353
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
354
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows-first experimental runtime defaults (Phase 2 wiring).
|
|
3
|
+
*
|
|
4
|
+
* These are the explicitly selected, owner-authorized experimental values
|
|
5
|
+
* recorded in `docs/v1/decision-log.md` (2026-10-11 entries) for a Windows
|
|
6
|
+
* experimental build only. They are NOT a production approval, not a format
|
|
7
|
+
* freeze, and not a general memory budget, compatibility, or support claim.
|
|
8
|
+
*
|
|
9
|
+
* - Crypto profile `sessionport-experimental-win-v1`: profile code 1,
|
|
10
|
+
* Argon2id13 `crypto_pwhash`, `opslimit=2`, `memlimit=128 MiB`, the current
|
|
11
|
+
* implementation's 16-byte random salt, 64-KiB plaintext frames, password cap
|
|
12
|
+
* 4 KiB, total encrypted artifact cap 66 MiB, decrypted gzip input cap
|
|
13
|
+
* 64 MiB, decompressed payload cap 256 MiB, expansion ratio 128 defined as
|
|
14
|
+
* decompressed payload bytes / decrypted compressed gzip bytes.
|
|
15
|
+
* - Collection bounds: session page size 50, message page size 200, at most
|
|
16
|
+
* 1,000 sessions, at most 10,000 messages per session, and at most 50,000
|
|
17
|
+
* messages total. Exceeding any bound fails the whole collection; nothing is
|
|
18
|
+
* ever truncated.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { CryptoPolicy } from "../../core/contracts/index.ts";
|
|
22
|
+
import type { CryptoPolicyDefinition } from "../../core/crypto/index.ts";
|
|
23
|
+
import type { SessionCollectionPolicy } from "../../core/sessions/index.ts";
|
|
24
|
+
|
|
25
|
+
/** Explicit allow-listed policy id for the Windows experimental profile. */
|
|
26
|
+
export const EXPERIMENTAL_CRYPTO_POLICY_ID = "sessionport-experimental-win-v1";
|
|
27
|
+
|
|
28
|
+
/** The explicit policy selector passed to the crypto factory; never read from an artifact. */
|
|
29
|
+
export const EXPERIMENTAL_CRYPTO_POLICY: CryptoPolicy = Object.freeze({
|
|
30
|
+
id: EXPERIMENTAL_CRYPTO_POLICY_ID,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Experimental password byte limit; the password UI is given the same value. */
|
|
34
|
+
export const EXPERIMENTAL_MAX_PASSWORD_BYTES = 4 * 1024;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The complete experimental allow-list definition. Numeric values are the
|
|
38
|
+
* owner-recorded Windows-experimental defaults; changing any value requires a
|
|
39
|
+
* new recorded decision and a fresh review (O-01/O-02/O-03 remain PARTIALLY
|
|
40
|
+
* RESOLVED for the experimental Windows build only).
|
|
41
|
+
*/
|
|
42
|
+
export const EXPERIMENTAL_CRYPTO_POLICY_DEFINITION: CryptoPolicyDefinition = deepFreeze({
|
|
43
|
+
id: EXPERIMENTAL_CRYPTO_POLICY_ID,
|
|
44
|
+
profileCode: 1,
|
|
45
|
+
kdf: {
|
|
46
|
+
opsLimit: 2,
|
|
47
|
+
memLimit: 128 * 1024 * 1024,
|
|
48
|
+
},
|
|
49
|
+
compression: {
|
|
50
|
+
level: undefined,
|
|
51
|
+
},
|
|
52
|
+
framing: {
|
|
53
|
+
chunkBytes: 64 * 1024,
|
|
54
|
+
},
|
|
55
|
+
limits: {
|
|
56
|
+
maxPasswordBytes: EXPERIMENTAL_MAX_PASSWORD_BYTES,
|
|
57
|
+
maxArtifactBytes: 66 * 1024 * 1024,
|
|
58
|
+
maxCompressedBytes: 64 * 1024 * 1024,
|
|
59
|
+
maxDecompressedBytes: 256 * 1024 * 1024,
|
|
60
|
+
maxExpansionRatio: 128,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The complete experimental collection policy. `maxTotalMessages` is the
|
|
66
|
+
* required core aggregate cap (zero is a valid fail-closed value in core; the
|
|
67
|
+
* experimental value is 50,000).
|
|
68
|
+
*/
|
|
69
|
+
export const EXPERIMENTAL_COLLECTION_POLICY: SessionCollectionPolicy = deepFreeze({
|
|
70
|
+
sessionPageSize: 50,
|
|
71
|
+
messagePageSize: 200,
|
|
72
|
+
maxSessions: 1000,
|
|
73
|
+
maxMessagesPerSession: 10000,
|
|
74
|
+
maxTotalMessages: 50000,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** Deep-freezes a plain JSON-shaped configuration object. */
|
|
78
|
+
function deepFreeze<T>(value: T): T {
|
|
79
|
+
if (value !== null && typeof value === "object") {
|
|
80
|
+
for (const key of Object.keys(value)) {
|
|
81
|
+
deepFreeze((value as Record<string, unknown>)[key]);
|
|
82
|
+
}
|
|
83
|
+
Object.freeze(value);
|
|
84
|
+
}
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionPort TUI wiring (Phase 2 experimental Windows lane).
|
|
3
|
+
*
|
|
4
|
+
* Public surface:
|
|
5
|
+
* - `installSessionPortWiring(context, options?)` — Windows-only install root:
|
|
6
|
+
* pinned-sodium initialization, real adapters/artifact I/O/password modal,
|
|
7
|
+
* command registration through the existing component-scoped seam, and the
|
|
8
|
+
* slot disposer returned unchanged.
|
|
9
|
+
* - `createSessionPortHandlers(dependencies)` — fully injected composition of
|
|
10
|
+
* the `/checkpoint` and `/restore` command callbacks (fake-based testable).
|
|
11
|
+
* - Experimental runtime defaults (crypto profile + collection bounds +
|
|
12
|
+
* password byte limit) and the sanitized report helpers.
|
|
13
|
+
*
|
|
14
|
+
* Boundaries: no shell/Git, no LLM/model call, no historical tool replay, no
|
|
15
|
+
* direct database access, no `project.update`; only already-sealed ciphertext
|
|
16
|
+
* is written, only encrypted artifacts are read, and every failure report is
|
|
17
|
+
* generic/sanitized. No production approval, format freeze, or platform-support
|
|
18
|
+
* claim is made (O-01/O-02/O-03 remain PARTIALLY RESOLVED for the experimental
|
|
19
|
+
* Windows build only; OQ-16 stays OPEN).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
createSessionPortHandlers,
|
|
24
|
+
parseCheckpointOutputArgument,
|
|
25
|
+
parseRestoreArtifactArgument,
|
|
26
|
+
} from "./composition.ts";
|
|
27
|
+
export type {
|
|
28
|
+
SessionPortHandlerDependencies,
|
|
29
|
+
SessionPortNotifier,
|
|
30
|
+
SessionPortPasswordProvider,
|
|
31
|
+
} from "./composition.ts";
|
|
32
|
+
export { installSessionPortWiring } from "./install.ts";
|
|
33
|
+
export type { SessionPortInstallOptions } from "./install.ts";
|
|
34
|
+
export {
|
|
35
|
+
EXPERIMENTAL_COLLECTION_POLICY,
|
|
36
|
+
EXPERIMENTAL_CRYPTO_POLICY,
|
|
37
|
+
EXPERIMENTAL_CRYPTO_POLICY_DEFINITION,
|
|
38
|
+
EXPERIMENTAL_CRYPTO_POLICY_ID,
|
|
39
|
+
EXPERIMENTAL_MAX_PASSWORD_BYTES,
|
|
40
|
+
} from "./experimental-profile.ts";
|
|
41
|
+
export {
|
|
42
|
+
reportCheckpointFailure,
|
|
43
|
+
reportCheckpointOutcome,
|
|
44
|
+
reportRestoreAuthenticationFailed,
|
|
45
|
+
reportRestoreCanceled,
|
|
46
|
+
reportRestoreCapabilityUnavailable,
|
|
47
|
+
reportRestoreExecution,
|
|
48
|
+
reportRestoreFailure,
|
|
49
|
+
reportRestoreUsage,
|
|
50
|
+
} from "./reports.ts";
|
|
51
|
+
export type { SessionPortNotification, SessionPortNotificationVariant } from "./reports.ts";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* SessionPort TUI install/composition root (Phase 2 experimental Windows wiring).
|
|
4
|
+
*
|
|
5
|
+
* `installSessionPortWiring` is the only host-facing composition entry:
|
|
6
|
+
*
|
|
7
|
+
* - **Windows-first experimental only.** On any non-`win32` platform it returns
|
|
8
|
+
* `undefined` immediately: no sodium initialization, no host-context access,
|
|
9
|
+
* no command registration, and no platform-support claim.
|
|
10
|
+
* - On Windows it initializes the pinned `libsodium-wrappers-sumo@0.8.4`
|
|
11
|
+
* runtime first (`await sodium.ready` plus primitive/constant validation
|
|
12
|
+
* through `createCheckpointCryptoFactory`; `createRemappedIdDeriver` also
|
|
13
|
+
* awaits/validates it) and fails closed: a rejected initialization aborts
|
|
14
|
+
* setup before any command is registered.
|
|
15
|
+
* - Native commands are registered through the existing component-scoped
|
|
16
|
+
* `installSessionPortCommands` (E39: the keymap layer must live inside the
|
|
17
|
+
* app-slot component scope). The slot disposer is returned unchanged so the
|
|
18
|
+
* host can clean up its contribution as the host contract permits; command
|
|
19
|
+
* handlers own their errors and report sanitized copy, so a handler never
|
|
20
|
+
* throws raw host data at the host.
|
|
21
|
+
* - Completed non-empty restore executions are presented through the
|
|
22
|
+
* designer-owned `showRestoreReportDialog` (aggregate/per-session facts only);
|
|
23
|
+
* empty results and dialog-display failures keep sanitized toast copy.
|
|
24
|
+
*
|
|
25
|
+
* This module statically imports the designer-owned UI modules (password modal
|
|
26
|
+
* and restore report dialog), which import the optional UI peers
|
|
27
|
+
* (`@opentui/solid`, `solid-js`). The entry (`src/tui.ts`) imports this module
|
|
28
|
+
* dynamically only after its `process.platform === "win32"` check, so
|
|
29
|
+
* non-Windows plugin setup never evaluates those modules.
|
|
30
|
+
*
|
|
31
|
+
* Test seams (platform/factory/deriver/password provider) exist only for
|
|
32
|
+
* fake-based tests; production calls use the real defaults. The real password
|
|
33
|
+
* provider is the designer-owned masked TUI modal, invoked with the
|
|
34
|
+
* experimental 4-KiB limit.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
38
|
+
import type { CheckpointCryptoFactory } from "../../core/contracts/index.ts";
|
|
39
|
+
import { createCheckpointCryptoFactory } from "../../core/crypto/index.ts";
|
|
40
|
+
import { createRemappedIdDeriver, type RemappedIdDeriver } from "../../core/restore/index.ts";
|
|
41
|
+
import { readEncryptedArtifact, resolveCheckpointOutputPath, writeEncryptedArtifact } from "../artifacts/files.ts";
|
|
42
|
+
import { requestPassword as showPasswordDialog } from "../ui/password-dialog.tsx";
|
|
43
|
+
import { showRestoreReportDialog } from "../ui/restore-report-dialog.tsx";
|
|
44
|
+
import { installSessionPortCommands } from "../ui/sessionport-commands.tsx";
|
|
45
|
+
import { createSessionPortHandlers, type SessionPortPasswordProvider } from "./composition.ts";
|
|
46
|
+
import { EXPERIMENTAL_CRYPTO_POLICY_DEFINITION } from "./experimental-profile.ts";
|
|
47
|
+
|
|
48
|
+
/** Test-only seams; production omits every field. */
|
|
49
|
+
export interface SessionPortInstallOptions {
|
|
50
|
+
/** Platform gate override for tests; defaults to `process.platform`. */
|
|
51
|
+
readonly platform?: string;
|
|
52
|
+
/** Crypto-factory seam; the default creates the experimental Windows profile and awaits pinned sodium. */
|
|
53
|
+
readonly createCryptoFactory?: () => Promise<CheckpointCryptoFactory>;
|
|
54
|
+
/** Remapped-ID deriver seam; the default awaits/validates pinned sodium. */
|
|
55
|
+
readonly createIdDeriver?: () => Promise<RemappedIdDeriver>;
|
|
56
|
+
/** Password-provider seam; the default is the designer-owned masked TUI modal. */
|
|
57
|
+
readonly requestPassword?: SessionPortPasswordProvider;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Installs the SessionPort commands for the experimental Windows lane and
|
|
62
|
+
* returns the slot disposer, or `undefined` when this platform is not
|
|
63
|
+
* registered. Fails closed (rejects) when the pinned runtime or the host
|
|
64
|
+
* command surface is unavailable; nothing is registered in that case.
|
|
65
|
+
*/
|
|
66
|
+
export async function installSessionPortWiring(
|
|
67
|
+
context: Plugin.Context,
|
|
68
|
+
options: SessionPortInstallOptions = {},
|
|
69
|
+
): Promise<(() => void) | undefined> {
|
|
70
|
+
// Windows-first experimental gate: no registration and no host access anywhere else.
|
|
71
|
+
if ((options.platform ?? process.platform) !== "win32") return undefined;
|
|
72
|
+
|
|
73
|
+
// Pinned-runtime initialization (fail closed): the default factory awaits
|
|
74
|
+
// `sodium.ready` and validates every primitive the provisional format needs;
|
|
75
|
+
// the deriver repeats the readiness/validation before registration.
|
|
76
|
+
const cryptoFactory = await (options.createCryptoFactory ?? createExperimentalCryptoFactory)();
|
|
77
|
+
const idDeriver = await (options.createIdDeriver ?? createRemappedIdDeriver)();
|
|
78
|
+
|
|
79
|
+
const handlers = createSessionPortHandlers({
|
|
80
|
+
client: context.client,
|
|
81
|
+
getActiveDirectory: () => context.location?.directory,
|
|
82
|
+
dialog: context.ui.dialog,
|
|
83
|
+
notify: (notification) => {
|
|
84
|
+
context.ui.toast.show({
|
|
85
|
+
title: notification.title,
|
|
86
|
+
message: notification.message,
|
|
87
|
+
variant: notification.variant,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
showRestoreReport: (model) => showRestoreReportDialog(context, model),
|
|
91
|
+
requestPassword:
|
|
92
|
+
options.requestPassword ?? ((mode, maxPasswordBytes) => showPasswordDialog(context, mode, maxPasswordBytes)),
|
|
93
|
+
cryptoFactory,
|
|
94
|
+
idDeriver,
|
|
95
|
+
resolveCheckpointOutputPath,
|
|
96
|
+
writeEncryptedArtifact,
|
|
97
|
+
readEncryptedArtifact,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// E39: registration must stay inside the existing app-slot/keymap
|
|
101
|
+
// component scope; the slot disposer is returned unchanged.
|
|
102
|
+
return installSessionPortCommands(context, handlers);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Default factory: the experimental Windows profile over the pinned runtime. */
|
|
106
|
+
async function createExperimentalCryptoFactory(): Promise<CheckpointCryptoFactory> {
|
|
107
|
+
return createCheckpointCryptoFactory([EXPERIMENTAL_CRYPTO_POLICY_DEFINITION]);
|
|
108
|
+
}
|