@logicseek/hello-cc 0.1.8 → 1.0.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/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +15 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +61 -8
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +232 -29
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5646 -262
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function eligibleBufferStat(stat) {
|
|
5
|
+
return stat.isFile() || stat.isFIFO();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function objectIdentity(stat) {
|
|
9
|
+
return `${stat.dev}:${stat.ino}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sameDirectoryObject(left, right) {
|
|
13
|
+
return left.isDirectory() &&
|
|
14
|
+
right.isDirectory() &&
|
|
15
|
+
left.dev === right.dev &&
|
|
16
|
+
left.ino === right.ino;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function statIdentity(stat) {
|
|
20
|
+
return {
|
|
21
|
+
dev: stat.dev,
|
|
22
|
+
ino: stat.ino,
|
|
23
|
+
mode: stat.mode,
|
|
24
|
+
size: stat.size,
|
|
25
|
+
mtimeMs: stat.mtimeMs,
|
|
26
|
+
ctimeMs: stat.ctimeMs
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function sameStatIdentity(left, right) {
|
|
31
|
+
return left.dev === right.dev &&
|
|
32
|
+
left.ino === right.ino &&
|
|
33
|
+
left.mode === right.mode &&
|
|
34
|
+
left.size === right.size &&
|
|
35
|
+
left.mtimeMs === right.mtimeMs &&
|
|
36
|
+
left.ctimeMs === right.ctimeMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function canonicalEntryPath(value) {
|
|
40
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
41
|
+
const resolved = path.resolve(value);
|
|
42
|
+
const suppliedParent = path.dirname(resolved);
|
|
43
|
+
try {
|
|
44
|
+
const suppliedStat = fs.statSync(suppliedParent);
|
|
45
|
+
const canonicalParent = fs.realpathSync(suppliedParent);
|
|
46
|
+
const canonicalStat = fs.lstatSync(canonicalParent);
|
|
47
|
+
if (canonicalStat.isSymbolicLink() || !sameDirectoryObject(suppliedStat, canonicalStat)) return null;
|
|
48
|
+
return path.join(canonicalParent, path.basename(resolved));
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function evidenceSnapshot(values) {
|
|
55
|
+
const paths = new Set();
|
|
56
|
+
const identities = new Set();
|
|
57
|
+
for (const value of values || []) {
|
|
58
|
+
const file = canonicalEntryPath(value);
|
|
59
|
+
if (!file) continue;
|
|
60
|
+
paths.add(file);
|
|
61
|
+
try {
|
|
62
|
+
const stat = fs.lstatSync(file);
|
|
63
|
+
if (!stat.isSymbolicLink() && eligibleBufferStat(stat)) identities.add(objectIdentity(stat));
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
return { paths, identities };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function refreshBufferPlanEvidence(plan, {
|
|
70
|
+
protectedPaths = [],
|
|
71
|
+
unknownPaths = [],
|
|
72
|
+
gcCutoffs = []
|
|
73
|
+
} = {}) {
|
|
74
|
+
if (!plan || !Array.isArray(plan.deleteEntries)) {
|
|
75
|
+
throw new TypeError('valid buffer GC plan required');
|
|
76
|
+
}
|
|
77
|
+
const protectedEvidence = evidenceSnapshot(protectedPaths);
|
|
78
|
+
const unknownEvidence = evidenceSnapshot(unknownPaths);
|
|
79
|
+
const protectedEntries = [];
|
|
80
|
+
const unknownEntries = [];
|
|
81
|
+
const deleteEntries = [];
|
|
82
|
+
for (const entry of plan.deleteEntries) {
|
|
83
|
+
const identity = `${entry?.stat?.dev}:${entry?.stat?.ino}`;
|
|
84
|
+
if (protectedEvidence.paths.has(entry.path) || protectedEvidence.identities.has(identity)) {
|
|
85
|
+
protectedEntries.push(entry);
|
|
86
|
+
} else if (unknownEvidence.paths.has(entry.path) || unknownEvidence.identities.has(identity)) {
|
|
87
|
+
unknownEntries.push(entry);
|
|
88
|
+
} else {
|
|
89
|
+
deleteEntries.push(entry);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
...plan,
|
|
94
|
+
evidenceGcCutoffs: [...new Set([
|
|
95
|
+
...(plan.evidenceGcCutoffs || []),
|
|
96
|
+
...gcCutoffs
|
|
97
|
+
])].sort((left, right) => left - right),
|
|
98
|
+
protectedPaths: protectedEntries.map((entry) => entry.path),
|
|
99
|
+
unknownPaths: unknownEntries.map((entry) => entry.path),
|
|
100
|
+
deletePaths: deleteEntries.map((entry) => entry.path),
|
|
101
|
+
protectedEntries,
|
|
102
|
+
unknownEntries,
|
|
103
|
+
deleteEntries
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function canonicalDirectories(values) {
|
|
108
|
+
const directories = [];
|
|
109
|
+
const seenPaths = new Set();
|
|
110
|
+
const seenIdentities = new Set();
|
|
111
|
+
for (const value of values || []) {
|
|
112
|
+
if (typeof value !== 'string' || value.length === 0) continue;
|
|
113
|
+
const resolved = path.resolve(value);
|
|
114
|
+
let suppliedStat;
|
|
115
|
+
try { suppliedStat = fs.lstatSync(resolved); } catch { continue; }
|
|
116
|
+
if (!suppliedStat.isDirectory() || suppliedStat.isSymbolicLink()) continue;
|
|
117
|
+
|
|
118
|
+
let directory;
|
|
119
|
+
let stat;
|
|
120
|
+
try {
|
|
121
|
+
directory = fs.realpathSync(resolved);
|
|
122
|
+
stat = fs.lstatSync(directory);
|
|
123
|
+
} catch {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (stat.isSymbolicLink() || !sameDirectoryObject(suppliedStat, stat)) continue;
|
|
127
|
+
const identity = objectIdentity(stat);
|
|
128
|
+
if (seenPaths.has(directory) || seenIdentities.has(identity)) continue;
|
|
129
|
+
seenPaths.add(directory);
|
|
130
|
+
seenIdentities.add(identity);
|
|
131
|
+
directories.push({ path: directory, identity });
|
|
132
|
+
}
|
|
133
|
+
return directories;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function planBufferFiles({
|
|
137
|
+
directories = [],
|
|
138
|
+
cutoffMs,
|
|
139
|
+
protectedPaths = [],
|
|
140
|
+
unknownPaths = [],
|
|
141
|
+
evidenceGcCutoffs = []
|
|
142
|
+
} = {}) {
|
|
143
|
+
if (!Number.isFinite(cutoffMs)) throw new TypeError('cutoffMs must be a finite number');
|
|
144
|
+
|
|
145
|
+
const protectedEvidence = evidenceSnapshot(protectedPaths);
|
|
146
|
+
const unknownEvidence = evidenceSnapshot(unknownPaths);
|
|
147
|
+
const seenIdentities = new Set();
|
|
148
|
+
const protectedEntries = [];
|
|
149
|
+
const unknownEntries = [];
|
|
150
|
+
const deleteEntries = [];
|
|
151
|
+
|
|
152
|
+
for (const directory of canonicalDirectories(directories)) {
|
|
153
|
+
let names;
|
|
154
|
+
try { names = fs.readdirSync(directory.path); } catch { continue; }
|
|
155
|
+
for (const name of names) {
|
|
156
|
+
const file = path.resolve(directory.path, name);
|
|
157
|
+
if (path.dirname(file) !== directory.path) continue;
|
|
158
|
+
|
|
159
|
+
let stat;
|
|
160
|
+
try { stat = fs.lstatSync(file); } catch { continue; }
|
|
161
|
+
if (stat.isSymbolicLink() || !eligibleBufferStat(stat) || stat.mtimeMs >= cutoffMs) continue;
|
|
162
|
+
const identity = objectIdentity(stat);
|
|
163
|
+
if (seenIdentities.has(identity)) continue;
|
|
164
|
+
seenIdentities.add(identity);
|
|
165
|
+
|
|
166
|
+
const entry = {
|
|
167
|
+
path: file,
|
|
168
|
+
stat: statIdentity(stat),
|
|
169
|
+
directory: { path: directory.path, identity: directory.identity }
|
|
170
|
+
};
|
|
171
|
+
if (protectedEvidence.paths.has(file) || protectedEvidence.identities.has(identity)) protectedEntries.push(entry);
|
|
172
|
+
else if (unknownEvidence.paths.has(file) || unknownEvidence.identities.has(identity)) unknownEntries.push(entry);
|
|
173
|
+
else deleteEntries.push(entry);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
cutoffMs,
|
|
179
|
+
evidenceGcCutoffs: [...evidenceGcCutoffs],
|
|
180
|
+
protectedPaths: protectedEntries.map((entry) => entry.path),
|
|
181
|
+
unknownPaths: unknownEntries.map((entry) => entry.path),
|
|
182
|
+
deletePaths: deleteEntries.map((entry) => entry.path),
|
|
183
|
+
protectedEntries,
|
|
184
|
+
unknownEntries,
|
|
185
|
+
deleteEntries
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function applyBufferPlan(plan) {
|
|
190
|
+
if (!plan || !Number.isFinite(plan.cutoffMs) || !Array.isArray(plan.deleteEntries)) {
|
|
191
|
+
throw new TypeError('valid buffer GC plan required');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const result = {
|
|
195
|
+
deleted: 0,
|
|
196
|
+
protected: plan.protectedEntries?.length || 0,
|
|
197
|
+
deferred: plan.unknownEntries?.length || 0
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
for (const entry of plan.deleteEntries) {
|
|
201
|
+
try {
|
|
202
|
+
const directory = fs.lstatSync(entry.directory.path);
|
|
203
|
+
if (directory.isSymbolicLink() ||
|
|
204
|
+
!directory.isDirectory() ||
|
|
205
|
+
objectIdentity(directory) !== entry.directory.identity ||
|
|
206
|
+
fs.realpathSync(entry.directory.path) !== entry.directory.path) {
|
|
207
|
+
result.deferred += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const current = fs.lstatSync(entry.path);
|
|
211
|
+
if (current.isSymbolicLink() ||
|
|
212
|
+
!eligibleBufferStat(current) ||
|
|
213
|
+
current.mtimeMs >= plan.cutoffMs ||
|
|
214
|
+
!sameStatIdentity(entry.stat, statIdentity(current))) {
|
|
215
|
+
result.deferred += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
fs.unlinkSync(entry.path);
|
|
219
|
+
result.deleted += 1;
|
|
220
|
+
} catch {
|
|
221
|
+
result.deferred += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function bufferPlanGcCutoffs(plan, retentionSec) {
|
|
228
|
+
if (!plan || !Number.isFinite(plan.cutoffMs)) {
|
|
229
|
+
throw new TypeError('valid buffer GC plan required');
|
|
230
|
+
}
|
|
231
|
+
if (!Number.isSafeInteger(retentionSec) || retentionSec < 0) {
|
|
232
|
+
throw new TypeError('retentionSec must be a non-negative safe integer');
|
|
233
|
+
}
|
|
234
|
+
const entryCutoffs = plan.deleteEntries.map((entry) => {
|
|
235
|
+
const boundary = Math.max(0, Math.floor(Number(entry?.stat?.mtimeMs) / 1000) + retentionSec);
|
|
236
|
+
if (!Number.isSafeInteger(boundary)) {
|
|
237
|
+
throw new TypeError('buffer GC boundary must be a non-negative safe integer');
|
|
238
|
+
}
|
|
239
|
+
return boundary;
|
|
240
|
+
});
|
|
241
|
+
const evidenceCutoffs = plan.evidenceGcCutoffs || [];
|
|
242
|
+
if (!Array.isArray(evidenceCutoffs) || evidenceCutoffs.some((cutoff) =>
|
|
243
|
+
!Number.isSafeInteger(cutoff) || cutoff < 0)) {
|
|
244
|
+
throw new TypeError('buffer evidence GC cutoffs must be non-negative safe integers');
|
|
245
|
+
}
|
|
246
|
+
return [...new Set([...entryCutoffs, ...evidenceCutoffs])].sort((left, right) => left - right);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function deferBufferPlan(plan) {
|
|
250
|
+
if (!plan || !Array.isArray(plan.deleteEntries)) {
|
|
251
|
+
throw new TypeError('valid buffer GC plan required');
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
deleted: 0,
|
|
255
|
+
protected: plan.protectedEntries?.length || 0,
|
|
256
|
+
deferred: (plan.unknownEntries?.length || 0) + plan.deleteEntries.length
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function pruneBufferFiles(options = {}) {
|
|
261
|
+
const plan = planBufferFiles(options);
|
|
262
|
+
if (options.dryRun) {
|
|
263
|
+
return {
|
|
264
|
+
deleted: plan.deleteEntries.length,
|
|
265
|
+
protected: plan.protectedEntries.length,
|
|
266
|
+
deferred: plan.unknownEntries.length
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
return applyBufferPlan(plan);
|
|
270
|
+
}
|
package/lib/runtime/client.mjs
CHANGED
|
@@ -1,32 +1,76 @@
|
|
|
1
1
|
import { CliError } from '../shared/errors.mjs';
|
|
2
2
|
import { readRuntime } from './state.mjs';
|
|
3
|
-
import { runtimeApiUrl } from '../web/runtime.mjs';
|
|
3
|
+
import { runtimeApiUrl, runtimeHttpRequest } from '../web/runtime.mjs';
|
|
4
|
+
import { withRuntimeApiVersionHeader } from '../web/api-version.mjs';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_CLI_NAME = 'hcc';
|
|
6
7
|
|
|
8
|
+
function requestAbortScope(signal, timeoutMs) {
|
|
9
|
+
const controller = new AbortController();
|
|
10
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
11
|
+
if (signal?.aborted) forwardAbort();
|
|
12
|
+
else signal?.addEventListener('abort', forwardAbort, { once: true });
|
|
13
|
+
|
|
14
|
+
const timeout = Number(timeoutMs);
|
|
15
|
+
const timer = Number.isFinite(timeout) && timeout >= 0
|
|
16
|
+
? setTimeout(() => controller.abort(new Error('runtime request deadline exceeded')), timeout)
|
|
17
|
+
: null;
|
|
18
|
+
return {
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
cleanup() {
|
|
21
|
+
if (timer) clearTimeout(timer);
|
|
22
|
+
signal?.removeEventListener('abort', forwardAbort);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function waitForAbort(promise, signal) {
|
|
28
|
+
if (!signal) return promise;
|
|
29
|
+
if (signal.aborted) return Promise.reject(signal.reason || new Error('runtime request aborted'));
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const abort = () => reject(signal.reason || new Error('runtime request aborted'));
|
|
32
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
33
|
+
Promise.resolve(promise).then(
|
|
34
|
+
(value) => {
|
|
35
|
+
signal.removeEventListener('abort', abort);
|
|
36
|
+
resolve(value);
|
|
37
|
+
},
|
|
38
|
+
(error) => {
|
|
39
|
+
signal.removeEventListener('abort', abort);
|
|
40
|
+
reject(error);
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
7
46
|
export async function runtimeRequest(ctx, method, route, body = null, runtime = null, opts = {}) {
|
|
8
47
|
const cliName = opts.cliName || DEFAULT_CLI_NAME;
|
|
9
|
-
const rt = runtime || readRuntime(ctx, { cliName });
|
|
48
|
+
const rt = runtime || readRuntime(ctx, { cliName, localOnly: Boolean(opts.localOnly) });
|
|
10
49
|
const url = runtimeApiUrl(rt, route);
|
|
11
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
50
|
+
const headers = withRuntimeApiVersionHeader({ 'Content-Type': 'application/json' });
|
|
12
51
|
headers['X-HCC-Root'] = ctx.root;
|
|
13
52
|
headers['X-HCC-DB'] = ctx.dbPath;
|
|
14
53
|
if (rt.token) headers.Authorization = `Bearer ${rt.token}`;
|
|
15
54
|
let res;
|
|
55
|
+
let text;
|
|
56
|
+
const abortScope = requestAbortScope(opts.signal, opts.timeoutMs);
|
|
16
57
|
try {
|
|
17
|
-
res = await
|
|
58
|
+
res = await waitForAbort(runtimeHttpRequest(rt, route, {
|
|
18
59
|
method,
|
|
19
60
|
headers,
|
|
20
|
-
body: body === null ?
|
|
21
|
-
|
|
61
|
+
body: body === null ? null : JSON.stringify(body),
|
|
62
|
+
signal: abortScope.signal
|
|
63
|
+
}), abortScope.signal);
|
|
64
|
+
text = res.text;
|
|
22
65
|
} catch (err) {
|
|
23
66
|
throw new CliError('RUNTIME_UNREACHABLE', `Runtime is not reachable at ${rt.base_url}. Start ${cliName} web again.`, {
|
|
24
67
|
runtime: rt.source || rt.base_url,
|
|
25
68
|
message: err.message
|
|
26
69
|
});
|
|
70
|
+
} finally {
|
|
71
|
+
abortScope.cleanup();
|
|
27
72
|
}
|
|
28
73
|
let json = null;
|
|
29
|
-
const text = await res.text();
|
|
30
74
|
if (text.trim()) {
|
|
31
75
|
try {
|
|
32
76
|
json = JSON.parse(text);
|
|
@@ -36,7 +80,16 @@ export async function runtimeRequest(ctx, method, route, body = null, runtime =
|
|
|
36
80
|
}
|
|
37
81
|
if (!res.ok) {
|
|
38
82
|
const error = json && json.error ? json.error : { code: 'RUNTIME_ERROR', message: `Runtime request failed: ${res.status}` };
|
|
39
|
-
throw new CliError(error.code || 'RUNTIME_ERROR', error.message || `Runtime request failed: ${res.status}`,
|
|
83
|
+
throw new CliError(error.code || 'RUNTIME_ERROR', error.message || `Runtime request failed: ${res.status}`, {
|
|
84
|
+
...error,
|
|
85
|
+
status: res.status
|
|
86
|
+
});
|
|
40
87
|
}
|
|
41
88
|
return json || {};
|
|
42
89
|
}
|
|
90
|
+
|
|
91
|
+
export function runtimeBufferGcUnavailable(error) {
|
|
92
|
+
return error?.code === 'RUNTIME_UNREACHABLE' ||
|
|
93
|
+
Number(error?.extra?.status) === 404 ||
|
|
94
|
+
Number(error?.extra?.status) === 426;
|
|
95
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { redactSecrets } from '../shared/redact.mjs';
|
|
2
|
+
|
|
3
|
+
function fatalReason(error) {
|
|
4
|
+
if (error instanceof Error) return redactSecrets(error.message || error.name);
|
|
5
|
+
if (typeof error === 'string') return redactSecrets(error);
|
|
6
|
+
return 'fatal runtime error';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createFatalShutdownController({
|
|
10
|
+
cleanup,
|
|
11
|
+
exit,
|
|
12
|
+
forceExit,
|
|
13
|
+
log,
|
|
14
|
+
schedule = setTimeout,
|
|
15
|
+
cancel = clearTimeout,
|
|
16
|
+
timeoutMs = 2_000
|
|
17
|
+
}) {
|
|
18
|
+
let state = 'idle';
|
|
19
|
+
|
|
20
|
+
function safeLog(entry) {
|
|
21
|
+
try { log(redactSecrets(entry)); } catch {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fatal(error) {
|
|
25
|
+
if (state !== 'idle') {
|
|
26
|
+
forceExit(1);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
state = 'stopping';
|
|
31
|
+
const deadline = schedule(() => {
|
|
32
|
+
state = 'forced';
|
|
33
|
+
forceExit(1);
|
|
34
|
+
}, timeoutMs);
|
|
35
|
+
deadline?.unref?.();
|
|
36
|
+
safeLog({ code: 'FATAL_RUNTIME_ERROR', error });
|
|
37
|
+
try {
|
|
38
|
+
await cleanup(fatalReason(error));
|
|
39
|
+
} catch (cleanupError) {
|
|
40
|
+
safeLog({ code: 'FATAL_CLEANUP_ERROR', error: cleanupError });
|
|
41
|
+
}
|
|
42
|
+
if (state === 'forced') return;
|
|
43
|
+
state = 'stopped';
|
|
44
|
+
let exitReturned = false;
|
|
45
|
+
try {
|
|
46
|
+
exit(1);
|
|
47
|
+
exitReturned = true;
|
|
48
|
+
} finally {
|
|
49
|
+
if (exitReturned) cancel(deadline);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { fatal };
|
|
54
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from '../shared/errors.mjs';
|
|
4
|
+
|
|
5
|
+
function pathIsWithin(parent, candidate) {
|
|
6
|
+
const relative = path.relative(parent, candidate);
|
|
7
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function projectPathForbidden(message) {
|
|
11
|
+
return new CliError('PROJECT_PATH_FORBIDDEN', message);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function canonicalProjectRoot(root) {
|
|
15
|
+
const requested = path.resolve(String(root || ''));
|
|
16
|
+
try {
|
|
17
|
+
const canonical = fs.realpathSync(requested);
|
|
18
|
+
if (!fs.statSync(canonical).isDirectory()) throw new Error('not a directory');
|
|
19
|
+
return canonical;
|
|
20
|
+
} catch {
|
|
21
|
+
throw new CliError('PROJECT_NOT_REGISTERED', `Project root does not exist: ${requested}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureStateDirectory(root, createStateDir) {
|
|
26
|
+
const stateDir = path.join(root, '.hello-cc');
|
|
27
|
+
let stateStat;
|
|
28
|
+
try {
|
|
29
|
+
stateStat = fs.lstatSync(stateDir);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code !== 'ENOENT') {
|
|
32
|
+
throw projectPathForbidden(`Cannot inspect project state directory: ${stateDir}`);
|
|
33
|
+
}
|
|
34
|
+
if (!createStateDir) return { stateDir, exists: false };
|
|
35
|
+
try {
|
|
36
|
+
fs.mkdirSync(stateDir, { mode: 0o700 });
|
|
37
|
+
stateStat = fs.lstatSync(stateDir);
|
|
38
|
+
} catch {
|
|
39
|
+
throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (stateStat.isSymbolicLink() || !stateStat.isDirectory()) {
|
|
44
|
+
throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const canonical = fs.realpathSync(stateDir);
|
|
48
|
+
if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
|
|
49
|
+
throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
|
|
50
|
+
}
|
|
51
|
+
return { stateDir: canonical, exists: true };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof CliError) throw error;
|
|
54
|
+
throw projectPathForbidden(`Cannot resolve project state directory: ${stateDir}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateDatabaseParent(stateDir, db) {
|
|
59
|
+
const parent = path.dirname(db);
|
|
60
|
+
const relative = path.relative(stateDir, parent);
|
|
61
|
+
const segments = relative === '' ? [] : relative.split(path.sep);
|
|
62
|
+
let current = stateDir;
|
|
63
|
+
for (const segment of segments) {
|
|
64
|
+
current = path.join(current, segment);
|
|
65
|
+
let stat;
|
|
66
|
+
try {
|
|
67
|
+
stat = fs.lstatSync(current);
|
|
68
|
+
} catch {
|
|
69
|
+
throw projectPathForbidden(`Database parent directory does not exist: ${current}`);
|
|
70
|
+
}
|
|
71
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
72
|
+
throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const canonicalParent = fs.realpathSync(parent);
|
|
78
|
+
if (canonicalParent !== stateDir && !pathIsWithin(stateDir, canonicalParent)) {
|
|
79
|
+
throw projectPathForbidden(`Database parent escapes project state directory: ${parent}`);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof CliError) throw error;
|
|
83
|
+
throw projectPathForbidden(`Cannot resolve database parent directory: ${parent}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateDatabaseTarget(stateDir, db) {
|
|
88
|
+
let stat;
|
|
89
|
+
try {
|
|
90
|
+
stat = fs.lstatSync(db);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === 'ENOENT') return db;
|
|
93
|
+
throw projectPathForbidden(`Cannot inspect project database: ${db}`);
|
|
94
|
+
}
|
|
95
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
96
|
+
throw projectPathForbidden(`Project database must be a regular file: ${db}`);
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const canonical = fs.realpathSync(db);
|
|
100
|
+
if (!pathIsWithin(stateDir, canonical)) {
|
|
101
|
+
throw projectPathForbidden(`Project database escapes project state directory: ${db}`);
|
|
102
|
+
}
|
|
103
|
+
return canonical;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof CliError) throw error;
|
|
106
|
+
throw projectPathForbidden(`Cannot resolve project database: ${db}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveProjectDatabase({ root, db, createStateDir = false }) {
|
|
111
|
+
const requestedRoot = path.resolve(String(root || ''));
|
|
112
|
+
const canonicalRoot = canonicalProjectRoot(requestedRoot);
|
|
113
|
+
const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
|
|
114
|
+
let requestedDb = pathIsWithin(requestedRoot, rawDb)
|
|
115
|
+
? path.join(canonicalRoot, path.relative(requestedRoot, rawDb))
|
|
116
|
+
: rawDb;
|
|
117
|
+
const intendedStateDir = path.join(canonicalRoot, '.hello-cc');
|
|
118
|
+
if (!pathIsWithin(intendedStateDir, requestedDb)) {
|
|
119
|
+
try {
|
|
120
|
+
const canonicalParent = fs.realpathSync(path.dirname(requestedDb));
|
|
121
|
+
const canonicalCandidate = path.join(canonicalParent, path.basename(requestedDb));
|
|
122
|
+
if (pathIsWithin(intendedStateDir, canonicalCandidate)) requestedDb = canonicalCandidate;
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
if (!pathIsWithin(intendedStateDir, requestedDb)) {
|
|
126
|
+
throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
|
|
127
|
+
}
|
|
128
|
+
const state = ensureStateDirectory(canonicalRoot, Boolean(createStateDir));
|
|
129
|
+
if (!state.exists) {
|
|
130
|
+
return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
validateDatabaseParent(state.stateDir, requestedDb);
|
|
134
|
+
return {
|
|
135
|
+
root: canonicalRoot,
|
|
136
|
+
stateDir: state.stateDir,
|
|
137
|
+
db: validateDatabaseTarget(state.stateDir, requestedDb)
|
|
138
|
+
};
|
|
139
|
+
}
|