opencode-session-port 0.0.0-stage → 0.1.0
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/package.json +56 -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 +12 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host import adapter: feature-detects and invokes the public TUI client
|
|
3
|
+
* `context.client.session.import` method for one planned session record.
|
|
4
|
+
*
|
|
5
|
+
* V1 policy boundaries:
|
|
6
|
+
* - O-12/OQ-12: the required capability is probed at restore start via
|
|
7
|
+
* `probeSessionImportCapability`, before the artifact is read or decrypted.
|
|
8
|
+
* The probe never throws; an absent or non-callable `session.import` is
|
|
9
|
+
* reported as `{ available: false, reason }` so the caller can show a clear
|
|
10
|
+
* "capability unavailable, no sessions were changed" message and stop.
|
|
11
|
+
* - O-16: every invocation supplies an explicit target
|
|
12
|
+
* (`location: { directory }`) provided by the caller. The adapter never
|
|
13
|
+
* infers a target and never derives one from the source record. It makes no
|
|
14
|
+
* promise that the source `info.projectID` or `info.location` survives; the
|
|
15
|
+
* host assigns target project scope (bounded E28/E31/E33 observations only).
|
|
16
|
+
* - The planned record's `info` and `messages` are passed through verbatim:
|
|
17
|
+
* this adapter performs no remapping, filtering, or field stripping.
|
|
18
|
+
* - Response hardening (fail closed at this dynamic boundary): the pinned
|
|
19
|
+
* v2.0.22 public client unwraps the raw protocol `{ data: SessionInfo }`
|
|
20
|
+
* envelope and resolves the declared `SessionInfo` directly, but the
|
|
21
|
+
* generated client casts the JSON body to that declared type without runtime
|
|
22
|
+
* validation. The adapter therefore re-proves the fields SessionPort relies
|
|
23
|
+
* on and fails closed with `invalid-response` unless the resolved value is a
|
|
24
|
+
* non-null, non-array object with nonblank string `id` and `projectID` and an
|
|
25
|
+
* object `location` with a nonblank string `directory`, and the returned
|
|
26
|
+
* `id` equals the planned record's `info.id`. It never expects or unwraps a
|
|
27
|
+
* `.data` envelope, never requires the returned directory to equal the
|
|
28
|
+
* requested target, and never requires `projectID` to equal any selected or
|
|
29
|
+
* source ID (no such equality is contract/runtime-proven here). On success
|
|
30
|
+
* the original response value is returned unchanged after narrowing.
|
|
31
|
+
* - Host/API errors (for example a conflict or validation rejection) propagate
|
|
32
|
+
* unchanged; this adapter never wraps, retries, or swallows them, so
|
|
33
|
+
* per-session failure reporting stays truthful. No retry semantics are
|
|
34
|
+
* implemented here (O-06 remains OPEN).
|
|
35
|
+
*
|
|
36
|
+
* Evidence boundary: the public import route is Experimental and the
|
|
37
|
+
* installed declaration mismatch (`SessionDomain` omits `import`) is bounded-
|
|
38
|
+
* addressed at runtime on one Windows v2.0.22 actual-TUI host (E33, one run,
|
|
39
|
+
* one import, no retry). These adapters are validated with synthetic
|
|
40
|
+
* fake-client tests only; no real host calls or imports are made by this lane,
|
|
41
|
+
* and no cross-version or support claim follows.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { SessionRecord } from "../../core/contracts/index.ts";
|
|
45
|
+
import {
|
|
46
|
+
HostAdapterError,
|
|
47
|
+
isObjectRecord,
|
|
48
|
+
requireNonEmptyString,
|
|
49
|
+
resolveUnaryMethod,
|
|
50
|
+
type SessionImportCallInput,
|
|
51
|
+
type SessionImportResult,
|
|
52
|
+
} from "./host-client.ts";
|
|
53
|
+
|
|
54
|
+
/** One import request: the final planned record plus the caller's explicit target. */
|
|
55
|
+
export interface SessionImportRequest {
|
|
56
|
+
/**
|
|
57
|
+
* Final planned record from restore preflight (`planRestore`). Passed
|
|
58
|
+
* through verbatim; the caller owns preflight validation.
|
|
59
|
+
*/
|
|
60
|
+
readonly record: SessionRecord;
|
|
61
|
+
/** Explicit destination target; always required, never inferred (O-16). */
|
|
62
|
+
readonly target: { readonly directory: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Capability probe outcome (O-12). `reason` is technical detail for the caller's message. */
|
|
66
|
+
export type SessionImportProbe = { readonly available: true } | { readonly available: false; readonly reason: string };
|
|
67
|
+
|
|
68
|
+
/** Single-session import surface. One call per planned record; no batching or retry. */
|
|
69
|
+
export interface SessionImportAdapter {
|
|
70
|
+
/**
|
|
71
|
+
* Invokes the public `session.import` once with `{ info, messages, location }`.
|
|
72
|
+
* Resolves with the validated narrow `SessionImportResult`: the original
|
|
73
|
+
* unwrapped host response value unchanged after fail-closed narrowing
|
|
74
|
+
* (`invalid-response` when the response violates the declared `SessionInfo`
|
|
75
|
+
* fields or its `id` does not match the planned record). Rejects with the
|
|
76
|
+
* unchanged host/API error.
|
|
77
|
+
*/
|
|
78
|
+
importSession(request: SessionImportRequest): Promise<SessionImportResult>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Feature-detects `client.session.import` as a callable. Never throws; callers
|
|
83
|
+
* must stop without importing when `available` is false.
|
|
84
|
+
*/
|
|
85
|
+
export function probeSessionImportCapability(client: unknown): SessionImportProbe {
|
|
86
|
+
const importMethod = resolveUnaryMethod<SessionImportCallInput>(client, "session", "import");
|
|
87
|
+
if (importMethod === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
available: false,
|
|
90
|
+
reason: "The installed TUI client does not expose a callable session.import",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { available: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates the import adapter. Fails closed with `capability-unavailable` when
|
|
98
|
+
* the probe would report unavailable; callers should probe first and surface
|
|
99
|
+
* the probe reason, but this factory re-checks so an import can never be
|
|
100
|
+
* attempted through an unverified surface.
|
|
101
|
+
*/
|
|
102
|
+
export function createSessionImportAdapter(client: unknown): SessionImportAdapter {
|
|
103
|
+
const importMethod = resolveUnaryMethod<SessionImportCallInput>(client, "session", "import");
|
|
104
|
+
if (importMethod === undefined) {
|
|
105
|
+
throw new HostAdapterError(
|
|
106
|
+
"capability-unavailable",
|
|
107
|
+
"The installed TUI client does not expose a callable session.import; refusing to import (no sessions were changed)",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
async importSession(request: SessionImportRequest): Promise<SessionImportResult> {
|
|
112
|
+
const response: unknown = await importMethod(buildSessionImportCall(request));
|
|
113
|
+
if (!isSessionImportResult(response)) {
|
|
114
|
+
throw new HostAdapterError(
|
|
115
|
+
"invalid-response",
|
|
116
|
+
`session.import did not return an unwrapped SessionInfo-shaped object with a nonblank string id, a nonblank string projectID, and a location.directory; received ${describeValue(response)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (response.id !== request.record.info["id"]) {
|
|
120
|
+
throw new HostAdapterError(
|
|
121
|
+
"invalid-response",
|
|
122
|
+
"session.import returned an id that does not match the imported record id",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return response;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Builds the exact public-client import payload: the planned record's `info`
|
|
132
|
+
* and `messages` verbatim plus the explicit `location` target. Validates the
|
|
133
|
+
* request shape fail-closed before any host call so malformed data is never
|
|
134
|
+
* handed to the host as a confusing API error.
|
|
135
|
+
*/
|
|
136
|
+
export function buildSessionImportCall(request: SessionImportRequest): SessionImportCallInput {
|
|
137
|
+
const rawRequest: unknown = request;
|
|
138
|
+
if (!isObjectRecord(rawRequest)) {
|
|
139
|
+
throw new HostAdapterError("invalid-request", "SessionImportRequest must be an object with record and target");
|
|
140
|
+
}
|
|
141
|
+
const rawRecord = rawRequest["record"];
|
|
142
|
+
if (!isObjectRecord(rawRecord) || !isObjectRecord(rawRecord["info"]) || !Array.isArray(rawRecord["messages"])) {
|
|
143
|
+
throw new HostAdapterError("invalid-request", "SessionImportRequest.record must be an object with info and messages");
|
|
144
|
+
}
|
|
145
|
+
const rawTarget = rawRequest["target"];
|
|
146
|
+
if (!isObjectRecord(rawTarget)) {
|
|
147
|
+
throw new HostAdapterError("invalid-request", "SessionImportRequest.target must be an object with a directory");
|
|
148
|
+
}
|
|
149
|
+
const directory = requireNonEmptyString(rawTarget["directory"], "SessionImportRequest.target.directory");
|
|
150
|
+
return { info: request.record.info, messages: request.record.messages, location: { directory } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------------------------- */
|
|
154
|
+
/* Response validation (fail closed, sanitized messages) */
|
|
155
|
+
/* ------------------------------------------------------------------------- */
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fail-closed narrowing of one untrusted `session.import` response.
|
|
159
|
+
*
|
|
160
|
+
* The pinned v2.0.22 public client resolves the already-unwrapped `SessionInfo`
|
|
161
|
+
* (the raw protocol `{ data }` envelope is unwrapped by the generated promise
|
|
162
|
+
* client), and the generated client casts the JSON body to the declared type
|
|
163
|
+
* without runtime validation. This guard is therefore the runtime proof behind
|
|
164
|
+
* `SessionImportResult`: a non-null, non-array object with nonblank string `id`
|
|
165
|
+
* and `projectID` and an object `location` carrying a nonblank string
|
|
166
|
+
* `directory`. It never unwraps a `{ data }`-shaped object (that is not the
|
|
167
|
+
* declared unwrapped contract) and never compares directories or project IDs;
|
|
168
|
+
* the caller compares the returned `id` to the planned record separately. The
|
|
169
|
+
* predicate is trusted only after every check in this body, and the response
|
|
170
|
+
* value itself is returned unchanged (same object identity).
|
|
171
|
+
*/
|
|
172
|
+
function isSessionImportResult(value: unknown): value is SessionImportResult {
|
|
173
|
+
if (!isObjectRecord(value)) return false;
|
|
174
|
+
const id = value["id"];
|
|
175
|
+
if (typeof id !== "string" || id.trim().length === 0) return false;
|
|
176
|
+
const projectID = value["projectID"];
|
|
177
|
+
if (typeof projectID !== "string" || projectID.trim().length === 0) return false;
|
|
178
|
+
const location = value["location"];
|
|
179
|
+
if (!isObjectRecord(location)) return false;
|
|
180
|
+
const directory = location["directory"];
|
|
181
|
+
return typeof directory === "string" && directory.trim().length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Type-only description for error messages; never includes the value itself. */
|
|
185
|
+
function describeValue(value: unknown): string {
|
|
186
|
+
if (value === null) return "null";
|
|
187
|
+
if (Array.isArray(value)) return "array";
|
|
188
|
+
return typeof value;
|
|
189
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host read adapter: maps the core `SessionReadPort` onto the actual public
|
|
3
|
+
* TUI client methods `context.client.session.list` and
|
|
4
|
+
* `context.client.message.list`.
|
|
5
|
+
*
|
|
6
|
+
* Mapping contract (v2.0.22 declaration/source research, E13/E14/E18):
|
|
7
|
+
* - `listSessions` sends exactly `{ project, limit }`, plus the opaque
|
|
8
|
+
* `cursor` only when the core request carries one. The project scope is
|
|
9
|
+
* always explicit and the page size is always explicit; no API default is
|
|
10
|
+
* relied on and no extra filter (`directory`, `search`, `parentID`,
|
|
11
|
+
* `subpath`, `order`) is ever invented.
|
|
12
|
+
* - `listMessages` sends `{ sessionID, limit, order: "asc" }` for the first
|
|
13
|
+
* page and `{ sessionID, limit, cursor }` for later pages, mirroring the
|
|
14
|
+
* declared constraint that `order` and `cursor` cannot be combined. No
|
|
15
|
+
* `type` filter is ever sent, so every message variant (including secret-
|
|
16
|
+
* shaped or tool-bearing records) is preserved; the endpoint-scoped
|
|
17
|
+
* `sessionID` is the ownership boundary.
|
|
18
|
+
* - Responses are returned to the caller as the raw `unknown` value the
|
|
19
|
+
* client resolved. This adapter does not parse, validate, filter, truncate,
|
|
20
|
+
* clone, or coerce pages or records: envelope validation, snapshotting, and
|
|
21
|
+
* fail-closed completeness checks belong to the core collector, and a
|
|
22
|
+
* malformed or partial page must fail there, never be silently repaired
|
|
23
|
+
* here.
|
|
24
|
+
*
|
|
25
|
+
* Capability behavior: construction fails closed with a `HostAdapterError`
|
|
26
|
+
* (`capability-unavailable`) when either method is missing or not callable,
|
|
27
|
+
* before any request is made. Method errors propagate unchanged (no wrapping)
|
|
28
|
+
* so collection failures stay truthful.
|
|
29
|
+
*
|
|
30
|
+
* Evidence boundary: declaration/source research and synthetic fake-client
|
|
31
|
+
* tests only. Runtime pagination/scope behavior against a live host is
|
|
32
|
+
* untested (OQ-10); no snapshot-consistency claim is made (O-21/OQ-23), and
|
|
33
|
+
* no cross-version or support claim follows.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { MessageListPageRequest, SessionListPageRequest, SessionReadPort } from "../../core/sessions/index.ts";
|
|
37
|
+
import {
|
|
38
|
+
HostAdapterError,
|
|
39
|
+
requireNonEmptyString,
|
|
40
|
+
requirePositiveSafeInteger,
|
|
41
|
+
resolveUnaryMethod,
|
|
42
|
+
type MessageListCallInput,
|
|
43
|
+
type SessionListCallInput,
|
|
44
|
+
} from "./host-client.ts";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Creates a `SessionReadPort` backed by the public TUI client.
|
|
48
|
+
*
|
|
49
|
+
* `client` is accepted as `unknown` (it is the host-provided
|
|
50
|
+
* `context.client`): the two required methods are resolved and validated at
|
|
51
|
+
* construction time. The returned port satisfies `SessionReadPort` exactly.
|
|
52
|
+
*/
|
|
53
|
+
export function createSessionReadAdapter(client: unknown): SessionReadPort {
|
|
54
|
+
const sessionList = resolveUnaryMethod<SessionListCallInput>(client, "session", "list");
|
|
55
|
+
if (sessionList === undefined) {
|
|
56
|
+
throw new HostAdapterError(
|
|
57
|
+
"capability-unavailable",
|
|
58
|
+
"The installed TUI client does not expose a callable session.list; cannot enumerate project sessions",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const messageList = resolveUnaryMethod<MessageListCallInput>(client, "message", "list");
|
|
62
|
+
if (messageList === undefined) {
|
|
63
|
+
throw new HostAdapterError(
|
|
64
|
+
"capability-unavailable",
|
|
65
|
+
"The installed TUI client does not expose a callable message.list; cannot enumerate session messages",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
async listSessions(request: SessionListPageRequest): Promise<unknown> {
|
|
71
|
+
return sessionList(mapSessionListRequest(request));
|
|
72
|
+
},
|
|
73
|
+
async listMessages(request: MessageListPageRequest): Promise<unknown> {
|
|
74
|
+
return messageList(mapMessageListRequest(request));
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Maps one core session page request onto the declared `session.list` input.
|
|
81
|
+
* The request is validated fail-closed first: a missing/blank project or a
|
|
82
|
+
* non-explicit page size would otherwise silently broaden the query scope or
|
|
83
|
+
* fall back to an API default.
|
|
84
|
+
*/
|
|
85
|
+
export function mapSessionListRequest(request: SessionListPageRequest): SessionListCallInput {
|
|
86
|
+
const project = requireNonEmptyString(request?.project, "SessionListPageRequest.project");
|
|
87
|
+
const limit = requirePositiveSafeInteger(request?.limit, "SessionListPageRequest.limit");
|
|
88
|
+
if (request.cursor === undefined) {
|
|
89
|
+
return { project, limit };
|
|
90
|
+
}
|
|
91
|
+
const cursor = requireNonEmptyString(request.cursor, "SessionListPageRequest.cursor");
|
|
92
|
+
return { project, limit, cursor };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Maps one core message page request onto the declared `message.list` input.
|
|
97
|
+
* The first page carries `order: "asc"` and no cursor; later pages carry only
|
|
98
|
+
* the opaque cursor. No `type` filter is ever added. The request is validated
|
|
99
|
+
* fail-closed first (explicit session scope and page size).
|
|
100
|
+
*/
|
|
101
|
+
export function mapMessageListRequest(request: MessageListPageRequest): MessageListCallInput {
|
|
102
|
+
const sessionID = requireNonEmptyString(request?.sessionID, "MessageListPageRequest.sessionID");
|
|
103
|
+
const limit = requirePositiveSafeInteger(request?.limit, "MessageListPageRequest.limit");
|
|
104
|
+
if ("cursor" in request) {
|
|
105
|
+
const cursor = requireNonEmptyString(request.cursor, "MessageListPageRequest.cursor");
|
|
106
|
+
return { sessionID, limit, cursor };
|
|
107
|
+
}
|
|
108
|
+
if (request.order !== "asc") {
|
|
109
|
+
throw new HostAdapterError(
|
|
110
|
+
"invalid-request",
|
|
111
|
+
'MessageListPageRequest must carry order "asc" on the first page (the declared API cannot combine order with cursor)',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return { sessionID, limit, order: "asc" };
|
|
115
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypted artifact filesystem adapter (Phase 4, non-visual lane; `fix-56`).
|
|
3
|
+
*
|
|
4
|
+
* Scope and boundaries:
|
|
5
|
+
* - P-15 artifact-path policy only: the default checkpoint directory is
|
|
6
|
+
* `<projectDirectory>/.sessionport/`, default artifact filenames are
|
|
7
|
+
* timestamped and Windows-safe, an existing artifact is never overwritten
|
|
8
|
+
* (path resolution and exclusive creation both fail closed), and an explicit
|
|
9
|
+
* output path overrides the default with ordinary absolute/relative
|
|
10
|
+
* resolution. The timestamp format and the `.sessionport` filename extension
|
|
11
|
+
* are local implementation details — not a file-format, extension, or
|
|
12
|
+
* compatibility freeze (O-02 remains OPEN).
|
|
13
|
+
* - Bytes only: `writeEncryptedArtifact` stores the already-sealed ciphertext
|
|
14
|
+
* `ByteSource` exactly as the crypto boundary produced it, and
|
|
15
|
+
* `readEncryptedArtifact` lazily streams the stored ciphertext back. This
|
|
16
|
+
* module has no crypto or password interface: it never encrypts, decrypts,
|
|
17
|
+
* authenticates, stages plaintext, selects profiles/parameters/caps, or
|
|
18
|
+
* creates temp files. Decryption/authentication stay in the crypto
|
|
19
|
+
* boundary (`openAndStage`).
|
|
20
|
+
*
|
|
21
|
+
* Filesystem behavior:
|
|
22
|
+
* - `writeEncryptedArtifact` creates the file with flag `"wx"` (exclusive
|
|
23
|
+
* create). An existing path fails with the original `EEXIST` error and is
|
|
24
|
+
* never truncated, overwritten, or auto-regenerated; the resolver's
|
|
25
|
+
* existence check is the early clear failure, and `"wx"` remains the
|
|
26
|
+
* authoritative race guard. Each write is awaited before the next source
|
|
27
|
+
* chunk is pulled (natural backpressure) and partial writes are looped to
|
|
28
|
+
* completion; the return value is the exact number of bytes written.
|
|
29
|
+
* - On any error after the exclusive open — including a source iterator
|
|
30
|
+
* failure, a write failure, or a close failure — only the partial file this
|
|
31
|
+
* call created is removed, best-effort, and the original error propagates
|
|
32
|
+
* unchanged (cleanup failures never replace it). An `EEXIST` from the open
|
|
33
|
+
* itself performs no cleanup because this call did not create the file.
|
|
34
|
+
* - `resolveCheckpointOutputPath` ensures the default `.sessionport/`
|
|
35
|
+
* directory exists (recursive mkdir) so the default path works on a fresh
|
|
36
|
+
* project; explicit override paths are resolved and collision-checked only
|
|
37
|
+
* (their parent directories are not created). The writer writes at exactly
|
|
38
|
+
* the given path and never creates directories or temp files.
|
|
39
|
+
* - Windows safety: the generated filename uses only `[A-Za-z0-9._-]` (no
|
|
40
|
+
* `:` from the UTC timestamp, no reserved device name), and drive-relative
|
|
41
|
+
* explicit paths (`C:foo`) fail closed instead of silently anchoring to the
|
|
42
|
+
* per-drive current directory.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/// <reference types="node" />
|
|
46
|
+
import { lstat, mkdir, open, unlink } from "node:fs/promises";
|
|
47
|
+
import * as path from "node:path";
|
|
48
|
+
import type { ByteSource } from "../../core/contracts/index.ts";
|
|
49
|
+
|
|
50
|
+
/* ------------------------------------------------------------------------- */
|
|
51
|
+
/* Errors */
|
|
52
|
+
/* ------------------------------------------------------------------------- */
|
|
53
|
+
|
|
54
|
+
/** Stable machine-readable failure codes for artifact path/file handling. */
|
|
55
|
+
export type ArtifactFileErrorCode =
|
|
56
|
+
/** The caller supplied an unusable path, timestamp, or option value. */
|
|
57
|
+
| "invalid-request"
|
|
58
|
+
/** The resolved output path already exists; nothing is overwritten. */
|
|
59
|
+
| "path-exists";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fail-closed artifact path/input error. Filesystem and source errors from an
|
|
63
|
+
* in-progress write are never wrapped in this class: they propagate unchanged
|
|
64
|
+
* so failure reporting stays truthful (only the resolver's early collision
|
|
65
|
+
* check and caller-input validation use this type).
|
|
66
|
+
*/
|
|
67
|
+
export class ArtifactFileError extends Error {
|
|
68
|
+
readonly code: ArtifactFileErrorCode;
|
|
69
|
+
|
|
70
|
+
constructor(code: ArtifactFileErrorCode, message: string, options?: ErrorOptions) {
|
|
71
|
+
super(message, options);
|
|
72
|
+
this.name = "ArtifactFileError";
|
|
73
|
+
this.code = code;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* ------------------------------------------------------------------------- */
|
|
78
|
+
/* Path resolution (P-15) */
|
|
79
|
+
/* ------------------------------------------------------------------------- */
|
|
80
|
+
|
|
81
|
+
/** Default checkpoint directory name under the project directory (P-15). */
|
|
82
|
+
const CHECKPOINT_DIRECTORY_NAME = ".sessionport";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Default filename shape. Prefix/extension and the UTC timestamp layout are
|
|
86
|
+
* implementation details (P-15/O-08), not a format or compatibility claim.
|
|
87
|
+
*/
|
|
88
|
+
const CHECKPOINT_FILENAME_PREFIX = "checkpoint-";
|
|
89
|
+
const CHECKPOINT_FILENAME_EXTENSION = ".sessionport";
|
|
90
|
+
|
|
91
|
+
/** Options for `resolveCheckpointOutputPath`. */
|
|
92
|
+
export interface ResolveCheckpointOutputPathOptions {
|
|
93
|
+
/** Current project directory; the default checkpoint directory sits beneath it. */
|
|
94
|
+
readonly projectDirectory: string;
|
|
95
|
+
/**
|
|
96
|
+
* Explicit output-path override (P-15). Absolute paths stay absolute;
|
|
97
|
+
* relative paths resolve against `projectDirectory`. Omitted → default.
|
|
98
|
+
*/
|
|
99
|
+
readonly explicitPath?: string;
|
|
100
|
+
/** Injectable current time for deterministic default filenames (tests). */
|
|
101
|
+
readonly now?: Date;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves the exact artifact output path under the P-15 policy and fails
|
|
106
|
+
* clearly (`ArtifactFileError`, code `path-exists`) when the path already
|
|
107
|
+
* exists. The path is never auto-regenerated or suffixed.
|
|
108
|
+
*
|
|
109
|
+
* Default: `<projectDirectory>/.sessionport/checkpoint-<UTC timestamp>.sessionport`
|
|
110
|
+
* (the directory is created if needed). Explicit override: normal absolute/
|
|
111
|
+
* relative resolution only — no extension, timestamp, or safety rewriting.
|
|
112
|
+
* The `.sessionport` extension and timestamp format are implementation
|
|
113
|
+
* details, not compatibility claims.
|
|
114
|
+
*/
|
|
115
|
+
export async function resolveCheckpointOutputPath(options: ResolveCheckpointOutputPathOptions): Promise<string> {
|
|
116
|
+
const input: unknown = options;
|
|
117
|
+
if (typeof input !== "object" || input === null) {
|
|
118
|
+
throw new ArtifactFileError("invalid-request", "resolveCheckpointOutputPath options must be an object");
|
|
119
|
+
}
|
|
120
|
+
const fields = input as {
|
|
121
|
+
readonly projectDirectory?: unknown;
|
|
122
|
+
readonly explicitPath?: unknown;
|
|
123
|
+
readonly now?: unknown;
|
|
124
|
+
};
|
|
125
|
+
const projectDirectory = requireNonEmptyString(fields.projectDirectory, "projectDirectory");
|
|
126
|
+
const now = fields.now;
|
|
127
|
+
if (now !== undefined && (!(now instanceof Date) || Number.isNaN(now.getTime()))) {
|
|
128
|
+
throw new ArtifactFileError("invalid-request", "now must be a valid Date when provided");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let resolved: string;
|
|
132
|
+
const explicitPath = fields.explicitPath;
|
|
133
|
+
if (explicitPath !== undefined) {
|
|
134
|
+
const explicit = requireNonEmptyString(explicitPath, "explicitPath");
|
|
135
|
+
rejectDriveRelativePath(explicit);
|
|
136
|
+
resolved = path.isAbsolute(explicit) ? path.resolve(explicit) : path.resolve(projectDirectory, explicit);
|
|
137
|
+
} else {
|
|
138
|
+
const defaultDirectory = path.resolve(projectDirectory, CHECKPOINT_DIRECTORY_NAME);
|
|
139
|
+
resolved = path.join(defaultDirectory, buildDefaultFilename(now ?? new Date()));
|
|
140
|
+
// The default location must be usable on a fresh project; creating the
|
|
141
|
+
// directory is idempotent and creates no file.
|
|
142
|
+
await mkdir(defaultDirectory, { recursive: true });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await assertPathDoesNotExist(resolved);
|
|
146
|
+
return resolved;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* UTC timestamp formatted with only Windows-safe characters (`:` replaced by
|
|
151
|
+
* `-`, milliseconds included, `Z` suffix). Deterministic for an injected Date.
|
|
152
|
+
*/
|
|
153
|
+
function buildDefaultFilename(instant: Date): string {
|
|
154
|
+
const pad = (value: number, width: number): string => String(value).padStart(width, "0");
|
|
155
|
+
const date = `${pad(instant.getUTCFullYear(), 4)}-${pad(instant.getUTCMonth() + 1, 2)}-${pad(instant.getUTCDate(), 2)}`;
|
|
156
|
+
const time = `${pad(instant.getUTCHours(), 2)}-${pad(instant.getUTCMinutes(), 2)}-${pad(instant.getUTCSeconds(), 2)}-${pad(instant.getUTCMilliseconds(), 3)}`;
|
|
157
|
+
return `${CHECKPOINT_FILENAME_PREFIX}${date}T${time}Z${CHECKPOINT_FILENAME_EXTENSION}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Fails closed on Windows drive-relative paths (`C:foo`): they are neither
|
|
162
|
+
* absolute nor project-relative, and ordinary resolution would silently
|
|
163
|
+
* anchor them to the per-drive current directory.
|
|
164
|
+
*/
|
|
165
|
+
function rejectDriveRelativePath(explicitPath: string): void {
|
|
166
|
+
if (process.platform === "win32" && !path.isAbsolute(explicitPath) && path.win32.parse(explicitPath).root.length > 0) {
|
|
167
|
+
throw new ArtifactFileError(
|
|
168
|
+
"invalid-request",
|
|
169
|
+
`explicitPath must be absolute or relative to projectDirectory (drive-relative paths are not supported): ${explicitPath}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Rejects (code `path-exists`) when any directory entry exists at `candidate`. */
|
|
175
|
+
async function assertPathDoesNotExist(candidate: string): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
// lstat: a dangling symlink is still an existing entry and must not be overwritten.
|
|
178
|
+
await lstat(candidate);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (isErrnoCode(error, "ENOENT")) return;
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
throw new ArtifactFileError("path-exists", `Refusing to overwrite an existing artifact: ${candidate}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isErrnoCode(value: unknown, code: string): boolean {
|
|
187
|
+
return typeof value === "object" && value !== null && (value as { readonly code?: unknown }).code === code;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------------------- */
|
|
191
|
+
/* Exclusive write / lazy read */
|
|
192
|
+
/* ------------------------------------------------------------------------- */
|
|
193
|
+
|
|
194
|
+
/** Streaming read chunk size; a fixed buffer, not a policy cap (O-03 OPEN). */
|
|
195
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Writes the provided already-sealed bytes directly to `targetPath` as an
|
|
199
|
+
* exclusive new file (`"wx"`) and resolves with the exact number of bytes
|
|
200
|
+
* written. Never overwrites an existing path, never creates a temp file, and
|
|
201
|
+
* never accepts crypto/password material.
|
|
202
|
+
*
|
|
203
|
+
* Backpressure: each `FileHandle.write` is awaited before the next source
|
|
204
|
+
* chunk is pulled, and partial writes are looped to completion. On any error
|
|
205
|
+
* after the exclusive open, only the partial file this call created is
|
|
206
|
+
* removed (best-effort); the original error propagates unchanged. The parent
|
|
207
|
+
* directory must already exist (use `resolveCheckpointOutputPath` for the
|
|
208
|
+
* default location).
|
|
209
|
+
*/
|
|
210
|
+
export async function writeEncryptedArtifact(targetPath: string, source: ByteSource): Promise<number> {
|
|
211
|
+
const pathString = requireNonEmptyString(targetPath, "path");
|
|
212
|
+
// Exclusive create: an existing path fails with the original EEXIST and is
|
|
213
|
+
// never truncated; this open is the authoritative overwrite guard.
|
|
214
|
+
const handle = await open(pathString, "wx");
|
|
215
|
+
let closed = false;
|
|
216
|
+
try {
|
|
217
|
+
let totalBytes = 0;
|
|
218
|
+
for await (const chunk of source) {
|
|
219
|
+
if (chunk.byteLength === 0) continue;
|
|
220
|
+
let offset = 0;
|
|
221
|
+
while (offset < chunk.byteLength) {
|
|
222
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset, null);
|
|
223
|
+
if (bytesWritten < 1) {
|
|
224
|
+
throw new Error(`artifact write made no progress at byte ${totalBytes + offset}`);
|
|
225
|
+
}
|
|
226
|
+
offset += bytesWritten;
|
|
227
|
+
}
|
|
228
|
+
totalBytes += offset;
|
|
229
|
+
}
|
|
230
|
+
await handle.close();
|
|
231
|
+
closed = true;
|
|
232
|
+
return totalBytes;
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (!closed) {
|
|
235
|
+
try {
|
|
236
|
+
await handle.close();
|
|
237
|
+
} catch {
|
|
238
|
+
// Best-effort only; the original error below is authoritative.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
// Only the partial file this call created (exclusive open succeeded).
|
|
243
|
+
await unlink(pathString);
|
|
244
|
+
} catch {
|
|
245
|
+
// Best-effort only; cleanup failures must never replace the original error.
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Lazily streams the stored ciphertext bytes from `targetPath` as fresh
|
|
253
|
+
* chunks. The file is opened only when iteration starts; this function
|
|
254
|
+
* performs no decryption, authentication, plaintext staging, or file
|
|
255
|
+
* creation/deletion. Read and open errors propagate unchanged.
|
|
256
|
+
*/
|
|
257
|
+
export function readEncryptedArtifact(targetPath: string): ByteSource {
|
|
258
|
+
const pathString = requireNonEmptyString(targetPath, "path");
|
|
259
|
+
return {
|
|
260
|
+
async *[Symbol.asyncIterator](): AsyncGenerator<Uint8Array> {
|
|
261
|
+
const handle = await open(pathString, "r");
|
|
262
|
+
try {
|
|
263
|
+
for (;;) {
|
|
264
|
+
const chunk = new Uint8Array(READ_CHUNK_BYTES);
|
|
265
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null);
|
|
266
|
+
if (bytesRead === 0) return;
|
|
267
|
+
yield bytesRead === chunk.byteLength ? chunk : chunk.subarray(0, bytesRead);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
await handle.close();
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/* ------------------------------------------------------------------------- */
|
|
277
|
+
/* Small validation helpers */
|
|
278
|
+
/* ------------------------------------------------------------------------- */
|
|
279
|
+
|
|
280
|
+
function requireNonEmptyString(value: unknown, label: string): string {
|
|
281
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
282
|
+
throw new ArtifactFileError("invalid-request", `${label} must be a non-empty string`);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|