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,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injected sequential restore execution (Phase 4, bounded orchestration).
|
|
3
|
+
*
|
|
4
|
+
* `runRestoreExecution` consumes one completed `RestorePlan` (from
|
|
5
|
+
* `planRestore`) plus an injected `SessionImportAdapter` and executes exactly
|
|
6
|
+
* `plan.importOrder` — the planner's deterministic parent-before-child order —
|
|
7
|
+
* one import at a time. This module performs no host call itself: the injected
|
|
8
|
+
* adapter owns the single public `session.import` invocation per planned entry
|
|
9
|
+
* and its explicit target (O-16).
|
|
10
|
+
*
|
|
11
|
+
* Owner handling policy (O-06, 2026-10-10; the final error taxonomy/report
|
|
12
|
+
* format stays OPEN as O-15):
|
|
13
|
+
* - No automatic retries: at most one `importSession` call per planned entry,
|
|
14
|
+
* ever.
|
|
15
|
+
* - An individual import failure is caught and recorded as `failed`; unrelated
|
|
16
|
+
* branches continue with the remaining `importOrder` entries.
|
|
17
|
+
* - Descendants by `info.parentID` of a parent that failed, was blocked, or was
|
|
18
|
+
* not attempted are marked `blocked` and are never imported; blocking
|
|
19
|
+
* propagates down the parent chain.
|
|
20
|
+
* - Exact skips from `plan.entries` initialize as `skipped` outcomes with their
|
|
21
|
+
* planner `skipReason` preserved; they are treated as already present, are
|
|
22
|
+
* never imported, and never block their descendants.
|
|
23
|
+
* - A `parentID` that resolves outside the plan is assumed to be an already
|
|
24
|
+
* validated destination/local session (the planner only preserves such
|
|
25
|
+
* references when the destination snapshot contains them) and never blocks.
|
|
26
|
+
* - `shouldContinue` is a minimal injected stop check (default: always
|
|
27
|
+
* continue; no host cancellation is wired). Once it reports `false` — or
|
|
28
|
+
* misbehaves by returning a nonboolean value or throwing — no further import
|
|
29
|
+
* is attempted: remaining independent planned entries are `not-attempted`,
|
|
30
|
+
* while descendants of an unattempted parent are `blocked` because that
|
|
31
|
+
* parent is absent. Stop-check misbehavior only latches the injected stop
|
|
32
|
+
* seam; the raw return value/error is never exposed, stringified, or logged,
|
|
33
|
+
* and importer failure handling is unchanged (a failed import is still
|
|
34
|
+
* recorded as `failed` and independent branches continue).
|
|
35
|
+
* - Every `plan.entries` entry receives exactly one outcome; a manual rerun
|
|
36
|
+
* stays user-controlled (deterministic O-05 IDs let it reuse the same
|
|
37
|
+
* targets), and no atomic rollback is attempted or promised (A-06).
|
|
38
|
+
*
|
|
39
|
+
* Secrecy and leak boundary: outcomes carry IDs, classifications, and statuses
|
|
40
|
+
* only. Host exceptions are caught and never rethrown, wrapped, stringified, or
|
|
41
|
+
* logged, so no session record content, message text, password material, or raw
|
|
42
|
+
* host error crosses this boundary; this module performs no console output.
|
|
43
|
+
* Error taxonomy and report formatting remain OPEN (O-15): these are raw
|
|
44
|
+
* per-session facts, not a user-facing report.
|
|
45
|
+
*
|
|
46
|
+
* Fail-closed input validation: the plan shape, the adapter surface, the
|
|
47
|
+
* explicit target directory, and the callable `shouldContinue` shape are
|
|
48
|
+
* validated before any import is attempted; a malformed plan (for example an
|
|
49
|
+
* `importOrder` that omits or repeats a planned entry, contains a skipped
|
|
50
|
+
* entry, is not parent-first, or an `exact-local-duplicate` skip whose
|
|
51
|
+
* `targetSessionID` differs from its `sourceSessionID`) throws a `TypeError`
|
|
52
|
+
* without calling the adapter. Only exact-local-duplicate skips must keep
|
|
53
|
+
* target equal to source (the planner never remaps a local duplicate);
|
|
54
|
+
* `exact-planned-retry` skips may legitimately be remapped, so no equality is
|
|
55
|
+
* imposed on them.
|
|
56
|
+
*
|
|
57
|
+
* Evidence boundary: exercised only with synthetic plans and fake importers in
|
|
58
|
+
* tests. No real host call, import, filesystem, crypto, or UI effect is
|
|
59
|
+
* performed by this lane, and no runtime, compatibility, or security claim
|
|
60
|
+
* follows; the adapter's Experimental-route and O-12/O-16 boundaries are
|
|
61
|
+
* unchanged.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import type { SessionRecord } from "../../core/contracts/index.ts";
|
|
65
|
+
import type {
|
|
66
|
+
RestoreClassification,
|
|
67
|
+
RestorePlan,
|
|
68
|
+
RestorePlanEntry,
|
|
69
|
+
RestoreSkipReason,
|
|
70
|
+
} from "../../core/restore/index.ts";
|
|
71
|
+
import type { SessionImportAdapter } from "../adapters/index.ts";
|
|
72
|
+
import { isRecordValue, requireCallable, requireNonEmptyString } from "./shared.ts";
|
|
73
|
+
|
|
74
|
+
/* ------------------------------------------------------------------------- */
|
|
75
|
+
/* Public result shape */
|
|
76
|
+
/* ------------------------------------------------------------------------- */
|
|
77
|
+
|
|
78
|
+
/** Truthful per-session execution status for one restore run (O-06). */
|
|
79
|
+
export type RestoreSessionExecutionStatus = "imported" | "skipped" | "failed" | "blocked" | "not-attempted";
|
|
80
|
+
|
|
81
|
+
/** One artifact session's outcome: planner classification plus execution status. */
|
|
82
|
+
export interface RestoreSessionOutcome {
|
|
83
|
+
/** Original artifact session ID; unique within the plan. */
|
|
84
|
+
readonly sourceSessionID: string;
|
|
85
|
+
/** Planned target session ID for this run (deterministic O-05 remap). */
|
|
86
|
+
readonly targetSessionID: string;
|
|
87
|
+
/** Planner classification preserved from `plan.entries` ("skipped" for exact skips). */
|
|
88
|
+
readonly classification: RestoreClassification;
|
|
89
|
+
/** Truthful execution status for this run. */
|
|
90
|
+
readonly status: RestoreSessionExecutionStatus;
|
|
91
|
+
/** Present exactly when `classification` is "skipped"; preserved from the plan. */
|
|
92
|
+
readonly skipReason?: RestoreSkipReason;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Raw per-session counts; no O-15 report format is implied. */
|
|
96
|
+
export interface RestoreExecutionSummary {
|
|
97
|
+
readonly total: number;
|
|
98
|
+
readonly imported: number;
|
|
99
|
+
readonly skipped: number;
|
|
100
|
+
readonly failed: number;
|
|
101
|
+
readonly blocked: number;
|
|
102
|
+
readonly notAttempted: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Complete outcome accounting for one execution run. */
|
|
106
|
+
export interface RestoreExecutionResult {
|
|
107
|
+
/** One outcome per `plan.entries` entry, in the same order. */
|
|
108
|
+
readonly outcomes: readonly RestoreSessionOutcome[];
|
|
109
|
+
readonly summary: RestoreExecutionSummary;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Explicit, fully injected execution dependencies. */
|
|
113
|
+
export interface RestoreExecutionInput {
|
|
114
|
+
/** Completed `planRestore` output; consumed as-is (order, classifications, skip reasons). */
|
|
115
|
+
readonly plan: RestorePlan;
|
|
116
|
+
/** Single-session import surface; one call per planned entry, no retries. */
|
|
117
|
+
readonly importer: SessionImportAdapter;
|
|
118
|
+
/** Explicit import target directory supplied on every call (O-16); never inferred. */
|
|
119
|
+
readonly targetDirectory: string;
|
|
120
|
+
/**
|
|
121
|
+
* Minimal stop check; defaults to always-continue. No host cancellation is
|
|
122
|
+
* wired. A thrown error or nonboolean return is treated as a stop for the
|
|
123
|
+
* remaining entries (never rethrown, wrapped, stringified, or logged);
|
|
124
|
+
* importer failure handling is unaffected.
|
|
125
|
+
*/
|
|
126
|
+
readonly shouldContinue?: () => boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ------------------------------------------------------------------------- */
|
|
130
|
+
/* Internal helpers */
|
|
131
|
+
/* ------------------------------------------------------------------------- */
|
|
132
|
+
|
|
133
|
+
interface MutableOutcome {
|
|
134
|
+
readonly sourceSessionID: string;
|
|
135
|
+
readonly targetSessionID: string;
|
|
136
|
+
readonly classification: RestoreClassification;
|
|
137
|
+
readonly skipReason?: RestoreSkipReason;
|
|
138
|
+
status: RestoreSessionExecutionStatus;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const CLASSIFICATIONS: ReadonlySet<string> = new Set(["skipped", "imported", "renamed"]);
|
|
142
|
+
const SKIP_REASONS: ReadonlySet<string> = new Set(["exact-local-duplicate", "exact-planned-retry"]);
|
|
143
|
+
|
|
144
|
+
function summarize(outcomes: readonly RestoreSessionOutcome[]): RestoreExecutionSummary {
|
|
145
|
+
const counts: Record<RestoreSessionExecutionStatus, number> = {
|
|
146
|
+
imported: 0,
|
|
147
|
+
skipped: 0,
|
|
148
|
+
failed: 0,
|
|
149
|
+
blocked: 0,
|
|
150
|
+
"not-attempted": 0,
|
|
151
|
+
};
|
|
152
|
+
for (const outcome of outcomes) counts[outcome.status] += 1;
|
|
153
|
+
return {
|
|
154
|
+
total: outcomes.length,
|
|
155
|
+
imported: counts.imported,
|
|
156
|
+
skipped: counts.skipped,
|
|
157
|
+
failed: counts.failed,
|
|
158
|
+
blocked: counts.blocked,
|
|
159
|
+
notAttempted: counts["not-attempted"],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* ------------------------------------------------------------------------- */
|
|
164
|
+
/* runRestoreExecution */
|
|
165
|
+
/* ------------------------------------------------------------------------- */
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Executes one completed restore plan sequentially in `plan.importOrder` order
|
|
169
|
+
* and returns truthful per-session outcomes. See the module comment for the
|
|
170
|
+
* exact handling policy, blocking rules, and validation boundary.
|
|
171
|
+
*/
|
|
172
|
+
export async function runRestoreExecution(input: RestoreExecutionInput): Promise<RestoreExecutionResult> {
|
|
173
|
+
if (!isRecordValue(input)) {
|
|
174
|
+
throw new TypeError("runRestoreExecution requires an input object with plan, importer, and targetDirectory");
|
|
175
|
+
}
|
|
176
|
+
if (!isRecordValue(input.importer) || typeof input.importer.importSession !== "function") {
|
|
177
|
+
throw new TypeError("runRestoreExecution requires an importer with an importSession(request) function");
|
|
178
|
+
}
|
|
179
|
+
const targetDirectory = requireNonEmptyString(input.targetDirectory, "targetDirectory");
|
|
180
|
+
if (input.shouldContinue !== undefined) {
|
|
181
|
+
requireCallable(input.shouldContinue, "shouldContinue");
|
|
182
|
+
}
|
|
183
|
+
const shouldContinue: () => boolean = input.shouldContinue ?? (() => true);
|
|
184
|
+
|
|
185
|
+
/* -- 1. Fail-closed plan-shape validation before any import. -- */
|
|
186
|
+
const rawPlan: unknown = input.plan;
|
|
187
|
+
if (!isRecordValue(rawPlan)) {
|
|
188
|
+
throw new TypeError("runRestoreExecution requires a completed RestorePlan object");
|
|
189
|
+
}
|
|
190
|
+
const rawEntries = rawPlan["entries"];
|
|
191
|
+
const rawImportOrder = rawPlan["importOrder"];
|
|
192
|
+
if (!Array.isArray(rawEntries) || !Array.isArray(rawImportOrder)) {
|
|
193
|
+
throw new TypeError("runRestoreExecution requires a completed RestorePlan with entries and importOrder arrays");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const entries: RestorePlanEntry[] = [];
|
|
197
|
+
const entryBySourceID = new Map<string, RestorePlanEntry>();
|
|
198
|
+
const outcomeBySourceID = new Map<string, MutableOutcome>();
|
|
199
|
+
const outcomeByTargetID = new Map<string, MutableOutcome>();
|
|
200
|
+
|
|
201
|
+
for (let index = 0; index < rawEntries.length; index++) {
|
|
202
|
+
const raw: unknown = rawEntries[index];
|
|
203
|
+
if (!isRecordValue(raw)) throw new TypeError(`plan.entries[${index}] must be a restore plan entry object`);
|
|
204
|
+
const sourceSessionID = requireNonEmptyString(raw["sourceSessionID"], `plan.entries[${index}].sourceSessionID`);
|
|
205
|
+
const targetSessionID = requireNonEmptyString(raw["targetSessionID"], `plan.entries[${index}].targetSessionID`);
|
|
206
|
+
const classification = raw["classification"];
|
|
207
|
+
if (typeof classification !== "string" || !CLASSIFICATIONS.has(classification)) {
|
|
208
|
+
throw new TypeError(`plan.entries[${index}].classification must be "skipped", "imported", or "renamed"`);
|
|
209
|
+
}
|
|
210
|
+
const rawSkipReason = raw["skipReason"];
|
|
211
|
+
let skipReason: RestoreSkipReason | undefined;
|
|
212
|
+
if (classification === "skipped") {
|
|
213
|
+
if (typeof rawSkipReason !== "string" || !SKIP_REASONS.has(rawSkipReason)) {
|
|
214
|
+
throw new TypeError(`plan.entries[${index}] is skipped and must preserve an exact planner skipReason`);
|
|
215
|
+
}
|
|
216
|
+
skipReason = rawSkipReason as RestoreSkipReason;
|
|
217
|
+
} else if (rawSkipReason !== undefined) {
|
|
218
|
+
throw new TypeError(`plan.entries[${index}] carries a skipReason but is not skipped`);
|
|
219
|
+
}
|
|
220
|
+
if (classification === "skipped" && skipReason === "exact-local-duplicate" && sourceSessionID !== targetSessionID) {
|
|
221
|
+
// A genuine exact-local-duplicate skip is an unchanged local session:
|
|
222
|
+
// the planner never remaps its target. A remapped target would let
|
|
223
|
+
// execution treat an absent session as already present, so fail closed
|
|
224
|
+
// before any import. Exact-planned-retry skips are deliberately exempt
|
|
225
|
+
// (a renamed retry may target a derived ID).
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
`plan.entries[${index}] is an exact-local-duplicate skip whose targetSessionID differs from its sourceSessionID; a local duplicate must target the same session`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const rawRecord = raw["record"];
|
|
231
|
+
if (!isRecordValue(rawRecord)) {
|
|
232
|
+
throw new TypeError(`plan.entries[${index}].record must be an object with info and messages`);
|
|
233
|
+
}
|
|
234
|
+
const rawInfo = rawRecord["info"];
|
|
235
|
+
const rawMessages = rawRecord["messages"];
|
|
236
|
+
if (!isRecordValue(rawInfo) || !Array.isArray(rawMessages)) {
|
|
237
|
+
throw new TypeError(`plan.entries[${index}].record must be an object with info and messages`);
|
|
238
|
+
}
|
|
239
|
+
const recordID = rawInfo["id"];
|
|
240
|
+
if (typeof recordID !== "string" || recordID !== targetSessionID) {
|
|
241
|
+
throw new TypeError(`plan.entries[${index}].record.info.id must equal the planned targetSessionID`);
|
|
242
|
+
}
|
|
243
|
+
const parentID = rawInfo["parentID"];
|
|
244
|
+
if (parentID !== undefined && typeof parentID !== "string") {
|
|
245
|
+
throw new TypeError(`plan.entries[${index}].record.info.parentID must be a string when present`);
|
|
246
|
+
}
|
|
247
|
+
if (entryBySourceID.has(sourceSessionID)) {
|
|
248
|
+
throw new TypeError(`plan.entries repeats sourceSessionID ${JSON.stringify(sourceSessionID)}`);
|
|
249
|
+
}
|
|
250
|
+
if (outcomeByTargetID.has(targetSessionID)) {
|
|
251
|
+
throw new TypeError(`plan.entries repeats targetSessionID ${JSON.stringify(targetSessionID)}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const entry: RestorePlanEntry = {
|
|
255
|
+
sourceSessionID,
|
|
256
|
+
targetSessionID,
|
|
257
|
+
classification: classification as RestoreClassification,
|
|
258
|
+
record: rawRecord as unknown as SessionRecord,
|
|
259
|
+
...(skipReason !== undefined ? { skipReason } : {}),
|
|
260
|
+
};
|
|
261
|
+
const outcome: MutableOutcome = {
|
|
262
|
+
sourceSessionID,
|
|
263
|
+
targetSessionID,
|
|
264
|
+
classification: entry.classification,
|
|
265
|
+
...(skipReason !== undefined ? { skipReason } : {}),
|
|
266
|
+
status: classification === "skipped" ? "skipped" : "not-attempted",
|
|
267
|
+
};
|
|
268
|
+
entries.push(entry);
|
|
269
|
+
entryBySourceID.set(sourceSessionID, entry);
|
|
270
|
+
outcomeBySourceID.set(sourceSessionID, outcome);
|
|
271
|
+
outcomeByTargetID.set(targetSessionID, outcome);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const order: RestorePlanEntry[] = [];
|
|
275
|
+
const orderIndexByTargetID = new Map<string, number>();
|
|
276
|
+
const orderSeen = new Set<string>();
|
|
277
|
+
for (let index = 0; index < rawImportOrder.length; index++) {
|
|
278
|
+
const raw: unknown = rawImportOrder[index];
|
|
279
|
+
if (!isRecordValue(raw)) throw new TypeError(`plan.importOrder[${index}] must be a restore plan entry object`);
|
|
280
|
+
const sourceSessionID = requireNonEmptyString(raw["sourceSessionID"], `plan.importOrder[${index}].sourceSessionID`);
|
|
281
|
+
const entry = entryBySourceID.get(sourceSessionID);
|
|
282
|
+
if (entry === undefined) {
|
|
283
|
+
throw new TypeError(
|
|
284
|
+
`plan.importOrder[${index}] references sourceSessionID ${JSON.stringify(sourceSessionID)} that is not in plan.entries`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (raw["targetSessionID"] !== entry.targetSessionID || raw["classification"] !== entry.classification) {
|
|
288
|
+
throw new TypeError(`plan.importOrder[${index}] does not match the corresponding plan.entries entry`);
|
|
289
|
+
}
|
|
290
|
+
if (entry.classification === "skipped") {
|
|
291
|
+
throw new TypeError(
|
|
292
|
+
`plan.importOrder[${index}] contains skipped session ${JSON.stringify(sourceSessionID)}; exact skips are never imported`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (orderSeen.has(sourceSessionID)) {
|
|
296
|
+
throw new TypeError(`plan.importOrder repeats sourceSessionID ${JSON.stringify(sourceSessionID)}; a retry is never performed`);
|
|
297
|
+
}
|
|
298
|
+
orderSeen.add(sourceSessionID);
|
|
299
|
+
orderIndexByTargetID.set(entry.targetSessionID, index);
|
|
300
|
+
order.push(entry);
|
|
301
|
+
}
|
|
302
|
+
for (const entry of entries) {
|
|
303
|
+
if (entry.classification === "skipped") continue;
|
|
304
|
+
if (!orderSeen.has(entry.sourceSessionID)) {
|
|
305
|
+
throw new TypeError(
|
|
306
|
+
`plan.importOrder omits planned session ${JSON.stringify(entry.sourceSessionID)}; every planned entry must be accounted for`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
for (let index = 0; index < order.length; index++) {
|
|
311
|
+
const entry = order[index]!;
|
|
312
|
+
const parentID = entry.record.info["parentID"];
|
|
313
|
+
if (typeof parentID !== "string") continue;
|
|
314
|
+
const parentIndex = orderIndexByTargetID.get(parentID);
|
|
315
|
+
if (parentIndex !== undefined && parentIndex >= index) {
|
|
316
|
+
throw new TypeError(
|
|
317
|
+
`plan.importOrder is not parent-first: ${JSON.stringify(entry.sourceSessionID)} appears before its planned parent ${JSON.stringify(parentID)}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* -- 2. Sequential execution in planner order. -- */
|
|
323
|
+
let stopLatched = false;
|
|
324
|
+
for (const entry of order) {
|
|
325
|
+
const outcome = outcomeBySourceID.get(entry.sourceSessionID)!;
|
|
326
|
+
const parentID = entry.record.info["parentID"];
|
|
327
|
+
if (typeof parentID === "string") {
|
|
328
|
+
const parent = outcomeByTargetID.get(parentID);
|
|
329
|
+
if (parent !== undefined && parent.status !== "imported" && parent.status !== "skipped") {
|
|
330
|
+
// The planned parent is absent: it failed, was blocked, or was
|
|
331
|
+
// never attempted, so this descendant is not imported.
|
|
332
|
+
outcome.status = "blocked";
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!stopLatched) {
|
|
337
|
+
let decision: unknown;
|
|
338
|
+
try {
|
|
339
|
+
decision = shouldContinue();
|
|
340
|
+
} catch {
|
|
341
|
+
// Misbehaving injected stop seam: treat it as a stop. The raw
|
|
342
|
+
// callback error never crosses this boundary (no rethrow, wrap,
|
|
343
|
+
// stringify, or log), and importer failure handling is unchanged.
|
|
344
|
+
decision = false;
|
|
345
|
+
}
|
|
346
|
+
// Only an explicit boolean `true` continues; `false` and any
|
|
347
|
+
// nonboolean value latch the stop for the remaining entries.
|
|
348
|
+
if (decision !== true) stopLatched = true;
|
|
349
|
+
}
|
|
350
|
+
if (stopLatched) {
|
|
351
|
+
// Remaining independent planned entries are not attempted; their
|
|
352
|
+
// descendants are blocked on the next iterations because this
|
|
353
|
+
// parent stays absent.
|
|
354
|
+
outcome.status = "not-attempted";
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
await input.importer.importSession({ record: entry.record, target: { directory: targetDirectory } });
|
|
359
|
+
outcome.status = "imported";
|
|
360
|
+
} catch {
|
|
361
|
+
// No retry, no wrapping, no logging: the failure is recorded as a
|
|
362
|
+
// truthful `failed` status and its raw error never crosses this
|
|
363
|
+
// boundary (O-15 taxonomy/report format stays OPEN).
|
|
364
|
+
outcome.status = "failed";
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* -- 3. Complete outcome accounting in plan.entries order. -- */
|
|
369
|
+
const outcomes: RestoreSessionOutcome[] = entries.map((entry) => {
|
|
370
|
+
const outcome = outcomeBySourceID.get(entry.sourceSessionID)!;
|
|
371
|
+
return {
|
|
372
|
+
sourceSessionID: outcome.sourceSessionID,
|
|
373
|
+
targetSessionID: outcome.targetSessionID,
|
|
374
|
+
classification: outcome.classification,
|
|
375
|
+
status: outcome.status,
|
|
376
|
+
...(outcome.skipReason !== undefined ? { skipReason: outcome.skipReason } : {}),
|
|
377
|
+
};
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
return { outcomes, summary: summarize(outcomes) };
|
|
381
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injected restore preflight (Phase 4, bounded orchestration; `fix-57`).
|
|
3
|
+
*
|
|
4
|
+
* `runRestorePreflight` performs capability detection, one password attempt,
|
|
5
|
+
* artifact authentication, strict payload parsing, the destination snapshot,
|
|
6
|
+
* the required cross-project session-ID occupancy inventory, and the pure
|
|
7
|
+
* `planRestore` preflight — and then stops. It never calls an
|
|
8
|
+
* importer and never touches destination history.
|
|
9
|
+
*
|
|
10
|
+
* Exact sequence (each step short-circuits the later ones):
|
|
11
|
+
*
|
|
12
|
+
* 1. `probeSessionImportCapability(client)` — the first externally
|
|
13
|
+
* observable action. If `session.import` is not callable, return a clear
|
|
14
|
+
* `capability-unavailable` outcome before the password provider, the
|
|
15
|
+
* artifact read, crypto creation, the destination snapshot, or any
|
|
16
|
+
* importer (O-12 policy).
|
|
17
|
+
* 2. `requestPassword("restore")` — one attempt. `undefined` ⇒ `canceled`
|
|
18
|
+
* outcome; the artifact is not read and crypto is not created. The
|
|
19
|
+
* owner-decided three-attempt TUI loop lives in the caller, and this
|
|
20
|
+
* helper deliberately does not classify crypto rejections as wrong
|
|
21
|
+
* password versus corrupt artifact; those errors propagate unchanged.
|
|
22
|
+
* 3. `readEncryptedArtifact(artifactPath)` — lazily constructs the
|
|
23
|
+
* ciphertext source (no file IO yet).
|
|
24
|
+
* 4. `cryptoFactory.create(cryptoPolicy)` — explicit injected policy only.
|
|
25
|
+
* 5. `crypto.openAndStage(artifact, password)` — full authentication and
|
|
26
|
+
* decompression must pass before any byte is parsed.
|
|
27
|
+
* 6. Strict UTF-8 decode (`fatal: true`, no BOM stripping, no Unicode
|
|
28
|
+
* normalization) followed by `JSON.parse` to `unknown`; invalid bytes or
|
|
29
|
+
* JSON fail closed before any destination read.
|
|
30
|
+
* 7. `readDestination(destinationProjectID)` — the complete destination
|
|
31
|
+
* `SessionRecord[]` snapshot, only after successful authentication and
|
|
32
|
+
* parse. The destination project ID is an explicit, caller-supplied
|
|
33
|
+
* input: a `SessionReadPort` requires a known project ID and cannot
|
|
34
|
+
* resolve an arbitrary target directory (OQ-10), so the source
|
|
35
|
+
* `info.projectID` is never substituted for the destination scope and no
|
|
36
|
+
* project/directory mapping is claimed.
|
|
37
|
+
* 8. `readGloballyVisibleSessionIDs()` — one required, bounded,
|
|
38
|
+
* metadata-only cross-project session-ID inventory (owner-approved
|
|
39
|
+
* 2026-10-14 amendment), invoked after authentication/parse and the
|
|
40
|
+
* destination snapshot and before planning. The returned IDs are
|
|
41
|
+
* occupancy evidence only; they can never satisfy destination references,
|
|
42
|
+
* classify an exact duplicate, or classify a retry (the planner enforces
|
|
43
|
+
* that), and a malformed inventory fails closed. Inventory errors
|
|
44
|
+
* propagate unchanged (their existing sanitized `SessionCollectionError`
|
|
45
|
+
* taxonomy) and the whole preflight stops with no import and no retry.
|
|
46
|
+
* 9. `planRestore({ payload, artifactIdentity, destination,
|
|
47
|
+
* globallyVisibleSessionIDs, idDeriver })` — schema validation, canonical
|
|
48
|
+
* duplicate classification, ID remapping (including deterministic
|
|
49
|
+
* cross-project collision renames), collision/dangling-reference checks,
|
|
50
|
+
* and the parent-before-child `plan.importOrder` run to completion before
|
|
51
|
+
* anything else happens.
|
|
52
|
+
*
|
|
53
|
+
* The password bytes are treated as flow-owned from the provider's resolution
|
|
54
|
+
* and are always best-effort zeroed in `finally` (success, auth failure,
|
|
55
|
+
* parse failure, destination failure, inventory failure, or planner failure).
|
|
56
|
+
* Best effort only — not secure deletion (A-07).
|
|
57
|
+
*
|
|
58
|
+
* Returned facts: the plan plus the explicit `targetDirectory` required by the
|
|
59
|
+
* caller (O-16: V1 always supplies an explicit import target and accepts the
|
|
60
|
+
* host assigning target project scope; no source `projectID`/`location`
|
|
61
|
+
* preservation is claimed). `plan.importOrder` is the only import-related
|
|
62
|
+
* output; no import executor, retry handling, partial-failure reporting, or
|
|
63
|
+
* product command exists in this lane (O-06/O-11/O-15 remain OPEN), and the
|
|
64
|
+
* adapter `createSessionImportAdapter` is deliberately not imported here.
|
|
65
|
+
*
|
|
66
|
+
* Evidence boundary: exercised only by fake clients, fake crypto, fake
|
|
67
|
+
* artifact sources, fake destination callbacks, and a fake inventory callback.
|
|
68
|
+
* No real host call, artifact read, KDF, destination enumeration, inventory
|
|
69
|
+
* scan, or import is performed by this lane, and no runtime or compatibility
|
|
70
|
+
* claim follows.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import {
|
|
74
|
+
type ByteSource,
|
|
75
|
+
type CheckpointCryptoFactory,
|
|
76
|
+
type CheckpointPayload,
|
|
77
|
+
type CryptoPolicy,
|
|
78
|
+
type SessionRecord,
|
|
79
|
+
} from "../../core/contracts/index.ts";
|
|
80
|
+
import {
|
|
81
|
+
planRestore,
|
|
82
|
+
type RemappedIdDeriver,
|
|
83
|
+
type RestorePlan,
|
|
84
|
+
} from "../../core/restore/index.ts";
|
|
85
|
+
import { probeSessionImportCapability } from "../adapters/index.ts";
|
|
86
|
+
import {
|
|
87
|
+
isRecordValue,
|
|
88
|
+
requireCallable,
|
|
89
|
+
requireNonEmptyString,
|
|
90
|
+
zeroPasswordBestEffort,
|
|
91
|
+
type PasswordProvider,
|
|
92
|
+
} from "./shared.ts";
|
|
93
|
+
|
|
94
|
+
/** Explicit, fully injected restore-preflight dependencies. Every field is required; no defaults exist. */
|
|
95
|
+
export interface RestorePreflightInput {
|
|
96
|
+
/** Artifact path to open; passed verbatim to the injected lazy reader. */
|
|
97
|
+
readonly artifactPath: string;
|
|
98
|
+
/** Known destination project scope for the local snapshot; never taken from the artifact (OQ-10). */
|
|
99
|
+
readonly destinationProjectID: string;
|
|
100
|
+
/** Explicit import target directory the caller will use after a successful plan (O-16). */
|
|
101
|
+
readonly targetDirectory: string;
|
|
102
|
+
/** Caller-supplied crypto factory; no production factory or profile is created here. */
|
|
103
|
+
readonly cryptoFactory: CheckpointCryptoFactory;
|
|
104
|
+
/** Explicit allow-listed policy for `factory.create`; never taken from an artifact. */
|
|
105
|
+
readonly cryptoPolicy: CryptoPolicy;
|
|
106
|
+
/** Injected `readEncryptedArtifact`; must return a lazy ciphertext source. */
|
|
107
|
+
readonly readEncryptedArtifact: (targetPath: string) => ByteSource;
|
|
108
|
+
/** Interactive password provider; called once per run, `undefined` means canceled. */
|
|
109
|
+
readonly requestPassword: PasswordProvider;
|
|
110
|
+
/** Complete local session snapshot for the explicit destination project ID, called only after auth+parse. */
|
|
111
|
+
readonly readDestination: (projectID: string) => Promise<readonly SessionRecord[]>;
|
|
112
|
+
/**
|
|
113
|
+
* Required cross-project session-ID occupancy inventory (owner-approved
|
|
114
|
+
* 2026-10-14 amendment), called once after auth+parse and the destination
|
|
115
|
+
* snapshot and before planning. Production wiring must supply the bounded
|
|
116
|
+
* metadata-only inventory (`createSessionIdInventory`); this dependency is
|
|
117
|
+
* deliberately not optional, so no production path can silently skip the
|
|
118
|
+
* cross-project collision check. Errors fail the whole preflight unchanged
|
|
119
|
+
* (existing sanitized taxonomy) with no import and no retry.
|
|
120
|
+
*/
|
|
121
|
+
readonly readGloballyVisibleSessionIDs: () => Promise<readonly string[]>;
|
|
122
|
+
/** Injected deterministic ID deriver; `planRestore` validates its outputs (O-22/OQ-24). */
|
|
123
|
+
readonly idDeriver: RemappedIdDeriver;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Restore-preflight outcome; imports remain unimplemented (O-06/O-11/O-15 OPEN). */
|
|
127
|
+
export type RestorePreflightOutcome =
|
|
128
|
+
/** O-12: `session.import` is not callable; nothing was read, decrypted, or changed. */
|
|
129
|
+
| { readonly status: "capability-unavailable"; readonly reason: string }
|
|
130
|
+
/** The user canceled the password prompt; the artifact was not read. */
|
|
131
|
+
| { readonly status: "canceled" }
|
|
132
|
+
/** Preflight completed; the caller owns the import target and any later import execution. */
|
|
133
|
+
| {
|
|
134
|
+
readonly status: "planned";
|
|
135
|
+
readonly plan: RestorePlan;
|
|
136
|
+
readonly targetDirectory: string;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Runs one restore preflight to a completed `RestorePlan` or to a clear
|
|
141
|
+
* capability-unavailable/canceled outcome. Errors (crypto rejection, invalid
|
|
142
|
+
* UTF-8/JSON, destination/planner failures) propagate unchanged; see the
|
|
143
|
+
* module comment for the exact sequence and policy boundaries.
|
|
144
|
+
*/
|
|
145
|
+
export async function runRestorePreflight(
|
|
146
|
+
client: unknown,
|
|
147
|
+
input: RestorePreflightInput,
|
|
148
|
+
): Promise<RestorePreflightOutcome> {
|
|
149
|
+
if (!isRecordValue(input)) {
|
|
150
|
+
throw new TypeError(
|
|
151
|
+
"runRestorePreflight requires an input object with explicit artifactPath, destinationProjectID, targetDirectory, cryptoFactory, cryptoPolicy, readEncryptedArtifact, requestPassword, readDestination, readGloballyVisibleSessionIDs, and idDeriver",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
requireCallable(input.requestPassword, "requestPassword");
|
|
155
|
+
requireCallable(input.readEncryptedArtifact, "readEncryptedArtifact");
|
|
156
|
+
requireCallable(input.readDestination, "readDestination");
|
|
157
|
+
requireCallable(input.readGloballyVisibleSessionIDs, "readGloballyVisibleSessionIDs");
|
|
158
|
+
if (!isRecordValue(input.cryptoFactory) || typeof input.cryptoFactory.create !== "function") {
|
|
159
|
+
throw new TypeError("runRestorePreflight requires a CheckpointCryptoFactory with a create(policy) function");
|
|
160
|
+
}
|
|
161
|
+
if (!isRecordValue(input.idDeriver) || typeof input.idDeriver.derive !== "function") {
|
|
162
|
+
throw new TypeError("runRestorePreflight requires a RemappedIdDeriver with a derive(namespace, identity, id) function");
|
|
163
|
+
}
|
|
164
|
+
requireNonEmptyString(input.artifactPath, "artifactPath");
|
|
165
|
+
requireNonEmptyString(input.destinationProjectID, "destinationProjectID");
|
|
166
|
+
requireNonEmptyString(input.targetDirectory, "targetDirectory");
|
|
167
|
+
|
|
168
|
+
// O-12: the capability probe is the first externally observable action.
|
|
169
|
+
const probe = probeSessionImportCapability(client);
|
|
170
|
+
if (!probe.available) {
|
|
171
|
+
return { status: "capability-unavailable", reason: probe.reason };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const password = await input.requestPassword("restore");
|
|
175
|
+
if (password === undefined) {
|
|
176
|
+
return { status: "canceled" };
|
|
177
|
+
}
|
|
178
|
+
if (!(password instanceof Uint8Array)) {
|
|
179
|
+
throw new TypeError("The restore password provider must resolve with Uint8Array bytes or undefined");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Lazy ciphertext source: constructing it performs no IO; openAndStage pulls it.
|
|
184
|
+
const artifact = input.readEncryptedArtifact(input.artifactPath);
|
|
185
|
+
const crypto = await input.cryptoFactory.create(input.cryptoPolicy);
|
|
186
|
+
// Full authentication and decompression must pass before any parsing.
|
|
187
|
+
const authenticated = await crypto.openAndStage(artifact, password);
|
|
188
|
+
const payload = parseAuthenticatedJson(authenticated.bytes);
|
|
189
|
+
// The complete destination snapshot is read only after auth+parse.
|
|
190
|
+
const destination = await input.readDestination(input.destinationProjectID);
|
|
191
|
+
// Required cross-project session-ID occupancy inventory (2026-10-14
|
|
192
|
+
// owner amendment): metadata only, after auth+parse and the destination
|
|
193
|
+
// snapshot, before planning. Failures propagate unchanged with no import
|
|
194
|
+
// and no retry.
|
|
195
|
+
const globallyVisibleSessionIDs = await input.readGloballyVisibleSessionIDs();
|
|
196
|
+
// planRestore enforces payload/destination/inventory schema and the full
|
|
197
|
+
// preflight; the cast only satisfies the static boundary (the runtime
|
|
198
|
+
// value is unknown).
|
|
199
|
+
const plan = planRestore({
|
|
200
|
+
payload: payload as CheckpointPayload,
|
|
201
|
+
artifactIdentity: authenticated.artifactIdentity,
|
|
202
|
+
destination,
|
|
203
|
+
globallyVisibleSessionIDs,
|
|
204
|
+
idDeriver: input.idDeriver,
|
|
205
|
+
});
|
|
206
|
+
return { status: "planned", plan, targetDirectory: input.targetDirectory };
|
|
207
|
+
} finally {
|
|
208
|
+
// Best-effort only (A-07); always zeroed on success and every failure path.
|
|
209
|
+
zeroPasswordBestEffort(password);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Strict UTF-8 decode of the authenticated payload bytes, then `JSON.parse`
|
|
215
|
+
* to `unknown`. `fatal: true` rejects invalid byte sequences; `ignoreBOM:
|
|
216
|
+
* true` keeps a leading BOM as a character instead of silently stripping it,
|
|
217
|
+
* so it fails JSON parsing rather than being normalized away. No Unicode
|
|
218
|
+
* normalization is applied. Schema validation is left to `planRestore`.
|
|
219
|
+
*/
|
|
220
|
+
function parseAuthenticatedJson(bytes: Uint8Array): unknown {
|
|
221
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
222
|
+
return JSON.parse(text) as unknown;
|
|
223
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared injected-boundary types and fail-closed validation for the Phase 4
|
|
3
|
+
* orchestration helpers (`fix-57`).
|
|
4
|
+
*
|
|
5
|
+
* This module contains no host, filesystem, crypto, or UI code: every effect
|
|
6
|
+
* is supplied by the caller. It exists so the checkpoint flow and the restore
|
|
7
|
+
* preflight can share one password-provider contract and one best-effort
|
|
8
|
+
* zeroing helper without importing each other.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ByteSource } from "../../core/contracts/index.ts";
|
|
12
|
+
|
|
13
|
+
/** Which command requested the password; only used for provider context, never for policy. */
|
|
14
|
+
export type PasswordPurpose = "checkpoint" | "restore";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Interactive password-provider callback.
|
|
18
|
+
*
|
|
19
|
+
* Resolves with the password bytes (UTF-8, no normalization — owner decision
|
|
20
|
+
* recorded in O-07) or `undefined` when the user canceled. Ownership of a
|
|
21
|
+
* resolved `Uint8Array` transfers to the orchestration helper, which
|
|
22
|
+
* best-effort zeroes it in a `finally` block; the provider (and the UI behind
|
|
23
|
+
* it) must not retain or reuse the array after resolving.
|
|
24
|
+
*
|
|
25
|
+
* The callback is expected to own all interactive retry/confirmation loops:
|
|
26
|
+
* the owner-decided checkpoint two-entry match loop and the restore
|
|
27
|
+
* three-attempt loop are not implemented here, and this helper calls the
|
|
28
|
+
* provider once per command run. It makes no attempt to classify a rejected
|
|
29
|
+
* password as "wrong password" versus "corrupt artifact".
|
|
30
|
+
*/
|
|
31
|
+
export type PasswordProvider = (purpose: PasswordPurpose) => Promise<Uint8Array | undefined>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort overwrite of the password array owned by the orchestration
|
|
35
|
+
* helper. This is not secure deletion (A-07) and does not reach copies the
|
|
36
|
+
* provider/UI/runtime may have retained (JavaScript strings and garbage
|
|
37
|
+
* collector state are outside this module's control).
|
|
38
|
+
*/
|
|
39
|
+
export function zeroPasswordBestEffort(password: Uint8Array): void {
|
|
40
|
+
password.fill(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for non-null, non-array object values; used on untrusted caller inputs. */
|
|
44
|
+
export function isRecordValue(value: unknown): value is { readonly [key: string]: unknown } {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Wraps already-serialized plaintext bytes as a one-shot `ByteSource` so the
|
|
50
|
+
* crypto boundary's `seal` can consume the serializer's `Uint8Array` output.
|
|
51
|
+
* The single yielded chunk is the caller-provided array itself; the flow hands
|
|
52
|
+
* it over and never mutates or retains it afterwards.
|
|
53
|
+
*/
|
|
54
|
+
export function asOneShotByteSource(bytes: Uint8Array): ByteSource {
|
|
55
|
+
return {
|
|
56
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
|
|
57
|
+
yield bytes;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Requires a callable dependency so a missing injection fails before any effect. */
|
|
63
|
+
export function requireCallable(value: unknown, label: string): void {
|
|
64
|
+
if (typeof value !== "function") {
|
|
65
|
+
throw new TypeError(`${label} must be an injected function; no default is applied`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Requires a non-empty string dependency so an omitted explicit scope fails closed. */
|
|
70
|
+
export function requireNonEmptyString(value: unknown, label: string): string {
|
|
71
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
72
|
+
throw new TypeError(`${label} must be an explicit non-empty string; no default is applied`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|