@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3
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/CHANGELOG.md +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/codex-interactions.mjs +13 -11
- package/lib/integrations/dsh-collaboration.mjs +6 -12
- package/lib/integrations/dsh-cordis.mjs +17 -13
- package/lib/integrations/dsh.mjs +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- package/lib/runtime/paths.mjs +17 -2
- package/lib/runtime/private-file.mjs +76 -0
- package/lib/runtime/private-state.mjs +546 -0
- package/lib/runtime/project-context.mjs +2 -1
- package/lib/runtime/project-path.mjs +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +132 -80
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +105 -85
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +481 -219
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
// fail-closed path re-resolution, per-project DB connections, and
|
|
4
4
|
// session/peer lookup helpers used by the HTTP and WS layers.
|
|
5
5
|
|
|
6
|
-
import path from 'node:path';
|
|
7
6
|
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
8
|
import WebSocket from 'ws';
|
|
9
9
|
import { CliError } from '../shared/errors.mjs';
|
|
10
|
-
import { contextForProject } from '../runtime/paths.mjs';
|
|
10
|
+
import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
|
|
11
11
|
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
12
|
+
import { assertSelectedCwdSnapshot, captureSelectedCwdIdentity, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
12
13
|
import {
|
|
13
14
|
projectRecord,
|
|
14
15
|
readProjectRegistry,
|
|
@@ -32,30 +33,139 @@ export function createProjectContexts(deps) {
|
|
|
32
33
|
} = deps;
|
|
33
34
|
|
|
34
35
|
const projectContexts = new Map();
|
|
36
|
+
const MAX_IDLE_PROJECT_DESCRIPTORS = 32;
|
|
37
|
+
|
|
38
|
+
function changedProject() {
|
|
39
|
+
return new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// This is a comparison value, not an authority token: it contains only the
|
|
43
|
+
// canonical path and stat identity already used by CLI-carried requests.
|
|
44
|
+
function selectedProjectIdentity(projectCtx) {
|
|
45
|
+
projectCtx.rootIdentity?.assertUnchanged();
|
|
46
|
+
const { canonical, identity } = projectCtx.rootIdentity || {};
|
|
47
|
+
if (!canonical || !identity) throw changedProject();
|
|
48
|
+
return Buffer.from(JSON.stringify({ canonical, identity })).toString('base64url');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionBelongsToProject(session, projectCtx) {
|
|
52
|
+
if (session?.ctx?.dbPath && projectCtx?.dbPath &&
|
|
53
|
+
path.resolve(session.ctx.dbPath) !== path.resolve(projectCtx.dbPath)) return false;
|
|
54
|
+
const owner = session?.ctx?.rootIdentity || session?.rootIdentity;
|
|
55
|
+
const requested = projectCtx?.rootIdentity;
|
|
56
|
+
// New managed sessions must never be found through another inode merely
|
|
57
|
+
// because their old root pathname now resolves to the requested project.
|
|
58
|
+
if (owner || requested) return Boolean(owner && requested && sameSelectedCwdIdentity(owner, requested));
|
|
59
|
+
return true; // Pre-existing contexts retain their legacy lookup behavior.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hasLiveManagedSession(binding) {
|
|
63
|
+
for (const session of sessions.values()) {
|
|
64
|
+
if (['exited', 'closed'].includes(session.status)) continue;
|
|
65
|
+
const owner = session.ctx?.rootIdentity || session.rootIdentity;
|
|
66
|
+
if (sameSelectedCwdIdentity(owner, binding)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function dropIdleProjectContextDescriptors(except = null, maxIdle = MAX_IDLE_PROJECT_DESCRIPTORS) {
|
|
72
|
+
let retainedIdle = 0;
|
|
73
|
+
// Map insertion order is selection recency. Keep a small hot cache plus
|
|
74
|
+
// descriptors held by live managed sessions; registry-only and older idle
|
|
75
|
+
// projects retain immutable stat identities without consuming an FD.
|
|
76
|
+
for (const project of [...projectContexts.values()].reverse()) {
|
|
77
|
+
const binding = project.rootIdentity;
|
|
78
|
+
if (!binding || binding.mode !== 'descriptor' || binding === except || hasLiveManagedSession(binding)) continue;
|
|
79
|
+
retainedIdle += 1;
|
|
80
|
+
if (retainedIdle > maxIdle) binding.dropDescriptor();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function retainProjectContextDescriptor(projectCtx) {
|
|
85
|
+
const previous = projectCtx.rootIdentity;
|
|
86
|
+
if (!previous) throw changedProject();
|
|
87
|
+
previous.assertUnchanged();
|
|
88
|
+
if (previous.mode === 'descriptor') return previous;
|
|
89
|
+
const refreshed = captureSelectedCwdIdentity(previous.canonical);
|
|
90
|
+
try {
|
|
91
|
+
refreshed.assertUnchanged();
|
|
92
|
+
previous.assertUnchanged();
|
|
93
|
+
if (!sameSelectedCwdIdentity(previous, refreshed)) throw changedProject();
|
|
94
|
+
if (refreshed.mode !== 'descriptor' || hasLiveManagedSession(previous)) {
|
|
95
|
+
refreshed.release();
|
|
96
|
+
return previous;
|
|
97
|
+
}
|
|
98
|
+
projectCtx.rootIdentity = refreshed;
|
|
99
|
+
previous.release();
|
|
100
|
+
return refreshed;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
refreshed.release();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
35
106
|
|
|
36
107
|
function newSessionActionToken() {
|
|
37
108
|
return randomBytes(32).toString('base64url');
|
|
38
109
|
}
|
|
39
110
|
|
|
40
111
|
function rememberProject(projectCtx, { activity = false, register = false, nonblocking = false } = {}) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
112
|
+
const incoming = projectCtx.rootIdentity || captureSelectedCwdIdentity(projectCtx.root);
|
|
113
|
+
let retained = incoming;
|
|
114
|
+
try {
|
|
115
|
+
incoming.assertUnchanged();
|
|
116
|
+
const normalized = contextForProject(projectCtx.root, projectCtx.dbPath, { cwd: projectCtx.cwd, json: ctx.json });
|
|
117
|
+
if (normalized.root !== incoming.canonical) throw changedProject();
|
|
118
|
+
const existing = projectContexts.get(normalized.root);
|
|
119
|
+
let reusableContext = false;
|
|
120
|
+
if (existing?.rootIdentity && sameSelectedCwdIdentity(existing.rootIdentity, incoming)) {
|
|
121
|
+
try {
|
|
122
|
+
existing.rootIdentity.assertUnchanged();
|
|
123
|
+
reusableContext = existing.dbPath === normalized.dbPath;
|
|
124
|
+
// A dormant stat-only record is upgraded from this request's fresh
|
|
125
|
+
// descriptor. Live sessions keep their original binding until exit.
|
|
126
|
+
if (existing.rootIdentity.mode === 'descriptor' || incoming.mode === 'stat-only' ||
|
|
127
|
+
hasLiveManagedSession(existing.rootIdentity)) retained = existing.rootIdentity;
|
|
128
|
+
} catch { /* A released or stale binding is never reused. */ }
|
|
129
|
+
}
|
|
130
|
+
normalized.rootIdentity = retained;
|
|
131
|
+
const isNew = !existing;
|
|
132
|
+
// Activity refresh is already nonblocking and throttled before it tries
|
|
133
|
+
// the registry lock. Reusing the existing binding avoids one open FD per
|
|
134
|
+
// browser request while keeping the initial inode attached to sessions.
|
|
135
|
+
if (activity) registerProjectActivity(normalized);
|
|
136
|
+
else if (isNew || register) {
|
|
137
|
+
try {
|
|
138
|
+
registerProject(normalized, { nonblocking });
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (nonblocking && ['ERR_FILE_LOCK_BUSY', 'ERR_FILE_LOCK_TIMEOUT'].includes(error?.code)) {
|
|
141
|
+
throw new CliError('REGISTRY_BUSY', 'Project registry is busy; retry the request');
|
|
142
|
+
}
|
|
143
|
+
throw error;
|
|
53
144
|
}
|
|
54
|
-
throw error;
|
|
55
145
|
}
|
|
146
|
+
retained.assertUnchanged();
|
|
147
|
+
if (retained !== incoming) incoming.release();
|
|
148
|
+
const previous = existing?.rootIdentity;
|
|
149
|
+
if (reusableContext) {
|
|
150
|
+
// Background scanners retain this object. Refresh its idle binding in
|
|
151
|
+
// place only while both the selected directory and database match.
|
|
152
|
+
existing.rootIdentity = retained;
|
|
153
|
+
projectContexts.delete(normalized.root);
|
|
154
|
+
projectContexts.set(normalized.root, existing);
|
|
155
|
+
if (previous !== retained) previous.release();
|
|
156
|
+
dropIdleProjectContextDescriptors(retained);
|
|
157
|
+
return existing;
|
|
158
|
+
}
|
|
159
|
+
if (previous && previous !== retained) previous.release();
|
|
160
|
+
projectContexts.set(normalized.root, normalized);
|
|
161
|
+
dropIdleProjectContextDescriptors(retained);
|
|
162
|
+
return normalized;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (retained !== incoming || ![...projectContexts.values()].some(value => value.rootIdentity === incoming)) {
|
|
165
|
+
incoming.release();
|
|
166
|
+
}
|
|
167
|
+
throw error;
|
|
56
168
|
}
|
|
57
|
-
projectContexts.set(normalized.root, normalized);
|
|
58
|
-
return normalized;
|
|
59
169
|
}
|
|
60
170
|
|
|
61
171
|
function knownProjects() {
|
|
@@ -63,60 +173,134 @@ export function createProjectContexts(deps) {
|
|
|
63
173
|
if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
|
|
64
174
|
for (const project of rows) {
|
|
65
175
|
if (!projectContexts.has(project.root)) {
|
|
66
|
-
|
|
176
|
+
const normalized = contextForProject(project.root, project.db, { json: ctx.json });
|
|
177
|
+
try {
|
|
178
|
+
normalized.rootIdentity = captureSelectedCwdIdentity(normalized.root);
|
|
179
|
+
normalized.rootIdentity.dropDescriptor();
|
|
180
|
+
}
|
|
181
|
+
catch { /* An unavailable registered project remains discoverable, not active. */ }
|
|
182
|
+
projectContexts.set(project.root, normalized);
|
|
67
183
|
}
|
|
68
184
|
}
|
|
69
185
|
return rows;
|
|
70
186
|
}
|
|
71
187
|
|
|
72
188
|
function resolveWebProjectContext(root, db) {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
189
|
+
const selected = captureSelectedCwdIdentity(root);
|
|
190
|
+
let binding = selected;
|
|
191
|
+
try {
|
|
192
|
+
// Freeze the canonical target, not an alias that may later be retargeted
|
|
193
|
+
// while the originally selected directory remains available at its own
|
|
194
|
+
// pathname. The alias must still be unchanged throughout selection.
|
|
195
|
+
if (selected.requested !== selected.canonical) binding = captureSelectedCwdIdentity(selected.canonical);
|
|
196
|
+
selected.assertUnchanged();
|
|
197
|
+
// Preserve explicit legacy DB spellings under a selected alias while
|
|
198
|
+
// resolving state against the captured canonical directory only.
|
|
199
|
+
const selectedLegacy = path.join(selected.requested, '.hello-cc');
|
|
200
|
+
const relativeDb = db ? path.relative(selectedLegacy, path.resolve(String(db))) : null;
|
|
201
|
+
const underSelectedLegacy = relativeDb !== null && relativeDb !== '..' &&
|
|
202
|
+
!relativeDb.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeDb);
|
|
203
|
+
const stableDb = underSelectedLegacy
|
|
204
|
+
? path.join(binding.canonical, '.hello-cc', relativeDb)
|
|
205
|
+
: db;
|
|
206
|
+
const first = resolveProjectDatabase({ root: binding.canonical, db: stableDb, createStateDir: true });
|
|
207
|
+
selected.assertUnchanged();
|
|
208
|
+
binding.assertUnchanged();
|
|
209
|
+
if (first.root !== binding.canonical) throw changedProject();
|
|
210
|
+
const final = resolveProjectDatabase({ root: first.root, db: first.db, createStateDir: true });
|
|
211
|
+
selected.assertUnchanged();
|
|
212
|
+
binding.assertUnchanged();
|
|
213
|
+
if (final.root !== binding.canonical) throw changedProject();
|
|
214
|
+
return { ...contextForProject(final.root, final.db, { cwd: final.root, json: ctx.json }),
|
|
215
|
+
rootIdentity: binding };
|
|
216
|
+
} catch (error) {
|
|
217
|
+
binding.release();
|
|
218
|
+
throw error;
|
|
219
|
+
} finally {
|
|
220
|
+
if (selected !== binding) selected.release();
|
|
221
|
+
}
|
|
83
222
|
}
|
|
84
223
|
|
|
85
224
|
function connectWebProject(projectCtx, options = {}) {
|
|
225
|
+
const binding = projectCtx.rootIdentity;
|
|
226
|
+
binding?.assertUnchanged();
|
|
86
227
|
const final = resolveProjectDatabase({
|
|
87
228
|
root: projectCtx.root,
|
|
88
229
|
db: projectCtx.dbPath,
|
|
89
230
|
createStateDir: options.create !== false
|
|
90
231
|
});
|
|
91
|
-
|
|
232
|
+
binding?.assertUnchanged();
|
|
233
|
+
if (binding && final.root !== binding.canonical) throw changedProject();
|
|
234
|
+
const db = connect(contextForProject(final.root, final.db, {
|
|
92
235
|
cwd: final.root,
|
|
93
236
|
json: projectCtx.json
|
|
94
237
|
}), options);
|
|
238
|
+
try { binding?.assertUnchanged(); }
|
|
239
|
+
catch (error) { try { db.close(); } catch {} throw error; }
|
|
240
|
+
return db;
|
|
95
241
|
}
|
|
96
242
|
|
|
97
|
-
function projectFromRequest(req, url) {
|
|
98
|
-
const
|
|
243
|
+
function projectFromRequest(req, url, { requireIdentity = false } = {}) {
|
|
244
|
+
const selectedRoot = url.searchParams.get('root') ||
|
|
99
245
|
url.searchParams.get('project') ||
|
|
100
|
-
req.headers['x-hcc-root']
|
|
101
|
-
|
|
246
|
+
req.headers['x-hcc-root'];
|
|
247
|
+
const requestedRoot = selectedRoot || ctx.root;
|
|
102
248
|
const requestedDb = url.searchParams.get('db') ||
|
|
103
|
-
req.headers['x-hcc-db'] ||
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
249
|
+
req.headers['x-hcc-db'] || null;
|
|
250
|
+
let carried = null;
|
|
251
|
+
let resolved = null;
|
|
252
|
+
try {
|
|
253
|
+
const headerIdentity = req.headers['x-hcc-root-identity'];
|
|
254
|
+
const queryIdentity = url.searchParams.get('root_identity');
|
|
255
|
+
if (headerIdentity !== undefined && queryIdentity !== null && headerIdentity !== queryIdentity) {
|
|
256
|
+
throw changedProject();
|
|
257
|
+
}
|
|
258
|
+
const encoded = headerIdentity === undefined ? queryIdentity : headerIdentity;
|
|
259
|
+
const browserRequest = req.headers['x-hcc-browser'] === '1' || url.searchParams.get('browser') === '1';
|
|
260
|
+
const explicitSelection = (url.pathname === '/api/projects/select' && req.method === 'POST') ||
|
|
261
|
+
(url.pathname === '/api/projects' && req.method === 'POST');
|
|
262
|
+
if ((browserRequest || requireIdentity) && !explicitSelection && !encoded) throw changedProject();
|
|
263
|
+
// A request that names no project falls back to the Web process's
|
|
264
|
+
// startup root. Its automatic bootstrap is not an explicit selection of
|
|
265
|
+
// a new inode at the same pathname after that startup root was moved.
|
|
266
|
+
if (!selectedRoot && ctx.initialRootIdentity) {
|
|
267
|
+
assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
268
|
+
}
|
|
269
|
+
if (encoded !== undefined && encoded !== null) {
|
|
270
|
+
if (typeof encoded !== 'string' || encoded.length > 2048 || !/^[A-Za-z0-9_-]+$/.test(encoded)) {
|
|
271
|
+
throw changedProject();
|
|
272
|
+
}
|
|
273
|
+
let expected;
|
|
274
|
+
try { expected = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); }
|
|
275
|
+
catch { throw changedProject(); }
|
|
276
|
+
if (!expected || typeof expected.canonical !== 'string' || !path.isAbsolute(expected.canonical) ||
|
|
277
|
+
!expected.identity || typeof expected.identity.dev !== 'string' ||
|
|
278
|
+
typeof expected.identity.ino !== 'string' ||
|
|
279
|
+
!(typeof expected.identity.birthtimeNs === 'string' || expected.identity.birthtimeNs === null)) {
|
|
280
|
+
throw changedProject();
|
|
281
|
+
}
|
|
282
|
+
carried = captureSelectedCwdIdentity(requestedRoot, expected);
|
|
283
|
+
}
|
|
284
|
+
resolved = resolveWebProjectContext(requestedRoot, requestedDb || projectDbPath(requestedRoot));
|
|
285
|
+
if (carried && !sameSelectedCwdIdentity(carried, resolved.rootIdentity)) throw changedProject();
|
|
286
|
+
const remembered = rememberProject(resolved, { activity: true });
|
|
287
|
+
resolved = null;
|
|
288
|
+
return remembered;
|
|
289
|
+
} finally {
|
|
290
|
+
carried?.release();
|
|
291
|
+
resolved?.rootIdentity?.release();
|
|
292
|
+
}
|
|
109
293
|
}
|
|
110
294
|
|
|
111
295
|
function getSession(projectCtx, id, db = null) {
|
|
112
296
|
const direct = sessions.get(sessionKey(projectCtx, id));
|
|
113
|
-
if (direct) return direct;
|
|
297
|
+
if (direct && sessionBelongsToProject(direct, projectCtx)) return direct;
|
|
114
298
|
for (const session of sessionsForProject(projectCtx)) {
|
|
115
|
-
if (session.peerId === id) return session;
|
|
299
|
+
if (sessionBelongsToProject(session, projectCtx) && session.peerId === id) return session;
|
|
116
300
|
}
|
|
117
301
|
if (db) {
|
|
118
302
|
for (const session of sessionsForProject(projectCtx)) {
|
|
119
|
-
if (resolveSessionPeerId(db, session) === id) return session;
|
|
303
|
+
if (sessionBelongsToProject(session, projectCtx) && resolveSessionPeerId(db, session) === id) return session;
|
|
120
304
|
}
|
|
121
305
|
}
|
|
122
306
|
return null;
|
|
@@ -155,9 +339,16 @@ export function createProjectContexts(deps) {
|
|
|
155
339
|
throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action for " + peer + " requires the managed session action token", { peer });
|
|
156
340
|
}
|
|
157
341
|
assertSessionControl?.(session, provided, input.epoch);
|
|
342
|
+
projectCtx.rootIdentity?.assertUnchanged();
|
|
343
|
+
session.ctx?.rootIdentity?.assertUnchanged();
|
|
158
344
|
return actorPeer;
|
|
159
345
|
}
|
|
160
346
|
|
|
347
|
+
function releaseProjectContexts() {
|
|
348
|
+
for (const project of projectContexts.values()) project.rootIdentity?.release();
|
|
349
|
+
projectContexts.clear();
|
|
350
|
+
}
|
|
351
|
+
|
|
161
352
|
function knownPeerIds(projectCtx) {
|
|
162
353
|
const db = connectWebProject(projectCtx);
|
|
163
354
|
try {
|
|
@@ -175,8 +366,9 @@ export function createProjectContexts(deps) {
|
|
|
175
366
|
}
|
|
176
367
|
|
|
177
368
|
return {
|
|
178
|
-
projectContexts,
|
|
179
|
-
|
|
369
|
+
projectContexts, releaseProjectContexts, dropIdleProjectContextDescriptors,
|
|
370
|
+
retainProjectContextDescriptor,
|
|
371
|
+
newSessionActionToken, rememberProject, knownProjects, selectedProjectIdentity,
|
|
180
372
|
resolveWebProjectContext, connectWebProject, projectFromRequest,
|
|
181
373
|
getSession, readActionToken, resolveWebActionSession,
|
|
182
374
|
knownPeerIds, nextProjectSessionId
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { CliError } from '../shared/errors.mjs';
|
|
7
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
8
|
+
import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
|
|
9
|
+
import { PROJECT_FILE_LIMITS, projectFilePathParts, projectTextContent, isProjectTextPath,
|
|
10
|
+
projectContentRevision, prepareProjectFileParent, verifyProjectFileParent } from './project-files.mjs';
|
|
11
|
+
|
|
12
|
+
// Serialized into a built-ins-only Node child. The trusted bootstrap pins cwd
|
|
13
|
+
// to the validated parent inode; every mutation below uses a single basename.
|
|
14
|
+
// HCC writers share a cross-process lock. Other editors do not share that lock,
|
|
15
|
+
// so the final revision check is optimistic, not an OS-level compare-and-swap.
|
|
16
|
+
function fileWriteWorker(revisionOf) {
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const { createHash, randomBytes } = require('node:crypto');
|
|
19
|
+
const { TextDecoder } = require('node:util');
|
|
20
|
+
const textOf = bytes => {
|
|
21
|
+
try {
|
|
22
|
+
const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
23
|
+
if (/[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(value)) return null;
|
|
24
|
+
return value;
|
|
25
|
+
} catch { return null; }
|
|
26
|
+
};
|
|
27
|
+
let temporary = null, tempFd, oldFd, tempIdentity, published = false, attempted = false;
|
|
28
|
+
const fail = code => { const error = new Error(code); error.code = code; throw error; };
|
|
29
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink &&
|
|
30
|
+
a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
|
|
31
|
+
const ordinary = stat => stat.isFile() && stat.nlink === 1n;
|
|
32
|
+
const readBounded = (fd, stat, maxBytes, code) => {
|
|
33
|
+
if (stat.size > BigInt(maxBytes)) fail(code);
|
|
34
|
+
const result = Buffer.alloc(Number(stat.size));
|
|
35
|
+
let position = 0;
|
|
36
|
+
while (position < result.length) {
|
|
37
|
+
const count = fs.readSync(fd, result, position, result.length - position, position);
|
|
38
|
+
if (!count) fail(code);
|
|
39
|
+
position += count;
|
|
40
|
+
}
|
|
41
|
+
if (!same(stat, fs.fstatSync(fd, { bigint: true }))) fail(code);
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
let receipt, failure;
|
|
45
|
+
try {
|
|
46
|
+
const request = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
47
|
+
const name = request.name, bytes = Buffer.from(request.content, 'base64');
|
|
48
|
+
if (!name || /[\\/\x00-\x1f\x7f]/.test(name) || name === '.' || name === '..') fail('PROJECT_FILE_BAD_PATH');
|
|
49
|
+
let before;
|
|
50
|
+
if (request.operation === 'save') {
|
|
51
|
+
oldFd = fs.openSync(name, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
52
|
+
before = fs.fstatSync(oldFd, { bigint: true });
|
|
53
|
+
if (!ordinary(before)) fail('PROJECT_FILE_FORBIDDEN');
|
|
54
|
+
if (before.size > 1048576n) fail('PROJECT_FILE_TOO_LARGE');
|
|
55
|
+
const oldBytes = readBounded(oldFd, before, 1048576, 'PROJECT_FILE_CONFLICT');
|
|
56
|
+
if (!same(before, fs.fstatSync(oldFd, { bigint: true })) ||
|
|
57
|
+
revisionOf(before, oldBytes) !== request.revision) fail('PROJECT_FILE_CONFLICT');
|
|
58
|
+
if (textOf(oldBytes, false) === null) fail('PROJECT_FILE_FORBIDDEN');
|
|
59
|
+
} else if (request.operation === 'upload') {
|
|
60
|
+
try { fs.lstatSync(name); fail('PROJECT_FILE_EXISTS'); }
|
|
61
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
62
|
+
} else fail('PROJECT_FILE_BAD_PATH');
|
|
63
|
+
temporary = '.hcc-file-write-' + randomBytes(18).toString('hex');
|
|
64
|
+
tempFd = fs.openSync(temporary, fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
65
|
+
tempIdentity = fs.fstatSync(tempFd, { bigint: true });
|
|
66
|
+
let offset = 0;
|
|
67
|
+
while (offset < bytes.length) {
|
|
68
|
+
const written = fs.writeSync(tempFd, bytes, offset, bytes.length - offset, offset);
|
|
69
|
+
if (!written) fail('PROJECT_FILE_WRITE_UNAVAILABLE');
|
|
70
|
+
offset += written;
|
|
71
|
+
}
|
|
72
|
+
fs.fchmodSync(tempFd, request.operation === 'save' ? Number(before.mode & 0o777n) : 0o644);
|
|
73
|
+
fs.fsyncSync(tempFd);
|
|
74
|
+
/* PINNED_FILE_TEST_BEFORE_PUBLISH */
|
|
75
|
+
const prepared = fs.fstatSync(tempFd, { bigint: true });
|
|
76
|
+
if (!ordinary(prepared) || !same(prepared, fs.lstatSync(temporary, { bigint: true })) ||
|
|
77
|
+
!readBounded(tempFd, prepared, bytes.length, 'PROJECT_FILE_CHANGED').equals(bytes)) fail('PROJECT_FILE_CHANGED');
|
|
78
|
+
if (request.operation === 'save') {
|
|
79
|
+
const current = fs.lstatSync(name, { bigint: true }), oldStat = fs.fstatSync(oldFd, { bigint: true });
|
|
80
|
+
if (!ordinary(current) || !same(before, current) || !same(before, oldStat)) fail('PROJECT_FILE_CONFLICT');
|
|
81
|
+
// Re-read immediately before publication to detect same-inode edits too.
|
|
82
|
+
const currentBytes = readBounded(oldFd, oldStat, 1048576, 'PROJECT_FILE_CONFLICT');
|
|
83
|
+
if (revisionOf(fs.fstatSync(oldFd, { bigint: true }), currentBytes) !== request.revision) fail('PROJECT_FILE_CONFLICT');
|
|
84
|
+
attempted = true;
|
|
85
|
+
fs.renameSync(temporary, name);
|
|
86
|
+
published = true; temporary = null;
|
|
87
|
+
} else {
|
|
88
|
+
// link is an atomic, exclusive publication: a concurrent creator wins
|
|
89
|
+
// with EEXIST and readers never observe a partly uploaded file.
|
|
90
|
+
attempted = true;
|
|
91
|
+
try { fs.linkSync(temporary, name); }
|
|
92
|
+
catch (error) { if (error.code === 'EEXIST') { attempted = false; fail('PROJECT_FILE_EXISTS'); } throw error; }
|
|
93
|
+
published = true;
|
|
94
|
+
fs.unlinkSync(temporary); temporary = null;
|
|
95
|
+
}
|
|
96
|
+
/* PINNED_FILE_TEST_AFTER_PUBLISH */
|
|
97
|
+
const final = fs.fstatSync(tempFd, { bigint: true }), visible = fs.lstatSync(name, { bigint: true });
|
|
98
|
+
if (!ordinary(final) || !same(final, visible) || final.size !== BigInt(bytes.length)) fail('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
99
|
+
const finalBytes = readBounded(tempFd, final, bytes.length, 'PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
100
|
+
if (!finalBytes.equals(bytes) || !same(final, fs.lstatSync(name, { bigint: true }))) fail('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
101
|
+
const directoryFd = fs.openSync('.', fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
102
|
+
try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); }
|
|
103
|
+
receipt = { size: finalBytes.length, contentHash: createHash('sha256').update(finalBytes).digest('hex'),
|
|
104
|
+
...(request.operation === 'save' ? { saved: true, revision: revisionOf(final, finalBytes) } : { created: true }) };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
failure = published || attempted ? 'PROJECT_FILE_WRITE_UNCONFIRMED' : error.code || 'PROJECT_FILE_WRITE_UNAVAILABLE';
|
|
107
|
+
} finally {
|
|
108
|
+
// Only our random temporary inode may be removed. Never unlink the public
|
|
109
|
+
// name on errors: publication may already have succeeded.
|
|
110
|
+
if (temporary && tempIdentity) {
|
|
111
|
+
try {
|
|
112
|
+
const current = fs.lstatSync(temporary, { bigint: true });
|
|
113
|
+
if (current.isFile() && current.dev === tempIdentity.dev && current.ino === tempIdentity.ino) fs.unlinkSync(temporary);
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
for (const fd of [oldFd, tempFd]) if (fd !== undefined) { try { fs.closeSync(fd); } catch { failure = 'PROJECT_FILE_WRITE_UNCONFIRMED'; } }
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write(JSON.stringify(failure ? { error: { code: failure } } : { receipt }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function workerSource() {
|
|
122
|
+
return "const { createHash } = require('node:crypto'); (" + fileWriteWorker.toString() + ')(' + projectContentRevision.toString() + ');';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function exactKeys(value, keys) {
|
|
126
|
+
return value && typeof value === 'object' && !Array.isArray(value) &&
|
|
127
|
+
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
|
|
128
|
+
}
|
|
129
|
+
function validateSave(input) {
|
|
130
|
+
if (!exactKeys(input, ['path', 'revision', 'content']) || typeof input.revision !== 'string' ||
|
|
131
|
+
!/^v1-[a-f0-9]{64}$/.test(input.revision) || typeof input.content !== 'string') {
|
|
132
|
+
throw new CliError('BAD_REQUEST', 'Text saves require only path, preview revision and content');
|
|
133
|
+
}
|
|
134
|
+
projectFilePathParts(input.path, false);
|
|
135
|
+
if (!isProjectTextPath(input.path)) throw new CliError('PROJECT_FILE_FORBIDDEN', 'This file type is not editable text');
|
|
136
|
+
if (!input.content.isWellFormed()) throw new CliError('BAD_REQUEST', 'Text must contain valid Unicode');
|
|
137
|
+
const bytes = Buffer.from(input.content, 'utf8');
|
|
138
|
+
if (bytes.length > PROJECT_FILE_LIMITS.textBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Text edits are limited to 1 MiB');
|
|
139
|
+
if (projectTextContent(bytes, false) === null) throw new CliError('BAD_REQUEST', 'Text edits cannot contain binary control characters');
|
|
140
|
+
return bytes;
|
|
141
|
+
}
|
|
142
|
+
function validateUpload(input) {
|
|
143
|
+
if (!exactKeys(input, ['path', 'encoding', 'content']) || input.encoding !== 'base64' || typeof input.content !== 'string') {
|
|
144
|
+
throw new CliError('BAD_REQUEST', 'Uploads require only path, encoding:base64 and content');
|
|
145
|
+
}
|
|
146
|
+
projectFilePathParts(input.path, false);
|
|
147
|
+
if (input.content.length > Math.ceil(PROJECT_FILE_LIMITS.mediaBytes / 3) * 4) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Uploads are limited to 10 MiB');
|
|
148
|
+
if (input.content.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/.test(input.content)) {
|
|
149
|
+
throw new CliError('BAD_REQUEST', 'Upload content must be canonical base64');
|
|
150
|
+
}
|
|
151
|
+
const bytes = Buffer.from(input.content, 'base64');
|
|
152
|
+
if (bytes.toString('base64') !== input.content) throw new CliError('BAD_REQUEST', 'Upload content must be canonical base64');
|
|
153
|
+
if (bytes.length > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Uploads are limited to 10 MiB');
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
156
|
+
function failure(code) {
|
|
157
|
+
const messages = {
|
|
158
|
+
PROJECT_FILE_CONFLICT: 'The file changed since this preview; reload before saving your draft',
|
|
159
|
+
PROJECT_FILE_EXISTS: 'A file already exists at this path; uploads never overwrite it',
|
|
160
|
+
PROJECT_FILE_WRITE_UNCONFIRMED: 'The write result is unknown; read the file back and compare its content before retrying',
|
|
161
|
+
PROJECT_FILE_WRITE_UNAVAILABLE: 'The file could not be safely written',
|
|
162
|
+
PROJECT_FILE_FORBIDDEN: 'Only ordinary project files with one link may be edited',
|
|
163
|
+
PROJECT_FILE_TOO_LARGE: 'The file exceeds the allowed write size',
|
|
164
|
+
PROJECT_FILE_NOT_FOUND: 'The target file or its parent directory no longer exists',
|
|
165
|
+
PROJECT_FILE_CHANGED: 'The selected project directory changed before the write'
|
|
166
|
+
};
|
|
167
|
+
const normalized = code === 'ENOENT' || code === 'ENOTDIR' ? 'PROJECT_FILE_NOT_FOUND'
|
|
168
|
+
: ['EACCES', 'EPERM', 'ELOOP'].includes(code) ? 'PROJECT_FILE_FORBIDDEN'
|
|
169
|
+
: Object.hasOwn(messages, code) ? code : 'PROJECT_FILE_WRITE_UNAVAILABLE';
|
|
170
|
+
return new CliError(normalized, messages[normalized]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Creating or replacing a child changes directory timestamps and link counts.
|
|
174
|
+
// After publication only directory identity is stable, but the requested path
|
|
175
|
+
// must still resolve through the same ancestors before reporting success.
|
|
176
|
+
async function verifyPublishedParent(snapshot, handle) {
|
|
177
|
+
snapshot.rootIdentity?.assertUnchanged();
|
|
178
|
+
if (await fs.promises.realpath(snapshot.root) !== snapshot.base ||
|
|
179
|
+
await fs.promises.realpath(snapshot.absolute) !== snapshot.absolute) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
180
|
+
const identityMatches = (current, original) => current.isDirectory() &&
|
|
181
|
+
current.dev === original.dev && current.ino === original.ino && current.birthtimeNs === original.birthtimeNs;
|
|
182
|
+
for (const item of snapshot.chain) {
|
|
183
|
+
const current = await fs.promises.lstat(item.absolute, { bigint: true });
|
|
184
|
+
if (!identityMatches(current, item.stat)) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
185
|
+
}
|
|
186
|
+
if (!identityMatches(await handle.stat({ bigint: true }), snapshot.stat)) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
187
|
+
snapshot.rootIdentity?.assertUnchanged();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function writeProjectFile(root, input, operation, bytes, { rootIdentity, spawnProcess = spawnSync } = {}) {
|
|
191
|
+
if (!['darwin', 'linux'].includes(process.platform) || !fs.constants.O_NOFOLLOW || !fs.constants.O_DIRECTORY) throw failure('PROJECT_FILE_WRITE_UNAVAILABLE');
|
|
192
|
+
const parent = await prepareProjectFileParent(root, input.path, { rootIdentity });
|
|
193
|
+
let receipt;
|
|
194
|
+
try {
|
|
195
|
+
await verifyProjectFileParent(parent.snapshot, parent.handle);
|
|
196
|
+
rootIdentity?.assertUnchanged();
|
|
197
|
+
const expectedIdentity = { canonical: parent.snapshot.absolute, identity: {
|
|
198
|
+
dev: parent.snapshot.stat.dev.toString(), ino: parent.snapshot.stat.ino.toString(),
|
|
199
|
+
birthtimeNs: parent.snapshot.stat.birthtimeNs?.toString() || null } };
|
|
200
|
+
receipt = withFileLock(path.join(parent.snapshot.absolute, parent.name), () => {
|
|
201
|
+
rootIdentity?.assertUnchanged();
|
|
202
|
+
const binding = preparePinnedCwdLaunch(parent.snapshot.absolute, process.execPath, ['-e', workerSource()], {
|
|
203
|
+
purpose: 'maintenance', expectedIdentity,
|
|
204
|
+
env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
|
|
205
|
+
});
|
|
206
|
+
try {
|
|
207
|
+
const result = spawnProcess(binding.command, binding.args, { cwd: binding.cwd, env: binding.env,
|
|
208
|
+
input: JSON.stringify({ operation, name: parent.name, revision: input.revision, content: bytes.toString('base64') }),
|
|
209
|
+
encoding: 'utf8', timeout: 10000, maxBuffer: 65536 });
|
|
210
|
+
if (result.error || result.status !== 0) {
|
|
211
|
+
if (/HCC_PINNED_CWD_CHANGED:/.test(result.stderr || '')) throw failure('PROJECT_FILE_CHANGED');
|
|
212
|
+
// The worker may have published before a timeout or interrupted pipe.
|
|
213
|
+
throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
214
|
+
}
|
|
215
|
+
let value;
|
|
216
|
+
try { value = JSON.parse(result.stdout); } catch { throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
|
|
217
|
+
if (value.error) throw failure(value.error.code);
|
|
218
|
+
const valueReceipt = value.receipt;
|
|
219
|
+
if (!valueReceipt || valueReceipt.size !== bytes.length ||
|
|
220
|
+
valueReceipt.contentHash !== createHash('sha256').update(bytes).digest('hex') ||
|
|
221
|
+
(operation === 'save' ? valueReceipt.saved !== true || !/^v1-[a-f0-9]{64}$/.test(valueReceipt.revision) : valueReceipt.created !== true)) {
|
|
222
|
+
throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
223
|
+
}
|
|
224
|
+
// Record publication before the lock/bootstrap release paths run.
|
|
225
|
+
receipt = valueReceipt;
|
|
226
|
+
try { rootIdentity?.assertUnchanged(); } catch { throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
|
|
227
|
+
return valueReceipt;
|
|
228
|
+
} finally { binding.release(); }
|
|
229
|
+
}, { createParent: false, timeoutMs: 5000 });
|
|
230
|
+
await verifyPublishedParent(parent.snapshot, parent.handle);
|
|
231
|
+
return { path: input.path, ...receipt };
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (receipt) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED');
|
|
234
|
+
if (error instanceof CliError) throw error;
|
|
235
|
+
throw failure(error.code);
|
|
236
|
+
} finally {
|
|
237
|
+
try { await parent.handle.close(); }
|
|
238
|
+
catch { if (receipt) throw failure('PROJECT_FILE_WRITE_UNCONFIRMED'); }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function saveProjectFile(root, input, options) {
|
|
243
|
+
const bytes = validateSave(input);
|
|
244
|
+
return writeProjectFile(root, input, 'save', bytes, options);
|
|
245
|
+
}
|
|
246
|
+
export async function uploadProjectFile(root, input, options) {
|
|
247
|
+
const bytes = validateUpload(input);
|
|
248
|
+
return writeProjectFile(root, input, 'upload', bytes, options);
|
|
249
|
+
}
|