@logicseek/hello-cc 1.1.0-rc.8 → 1.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/CHANGELOG.md +107 -0
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +2 -0
- package/docs/commands.md +16 -0
- package/docs/commands.zh-CN.md +14 -0
- package/docs/dsh.md +6 -0
- package/docs/dsh.zh-CN.md +5 -0
- package/docs/native.md +21 -1
- package/docs/native.zh-CN.md +19 -1
- package/docs/private-state.md +115 -0
- package/docs/private-state.zh-CN.md +92 -0
- package/lib/cli/app.mjs +46 -5
- package/lib/cli/commands/hook.mjs +31 -4
- package/lib/cli/commands/mcp.mjs +6 -0
- package/lib/cli/commands/native.mjs +17 -4
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +196 -62
- package/lib/cli/commands/tmux.mjs +28 -19
- package/lib/cli/commands/up.mjs +25 -17
- package/lib/cli-runtime.mjs +16 -1
- package/lib/core/coordination/event-retention.mjs +2 -0
- package/lib/core/sessions/codex-thread-root.mjs +144 -0
- package/lib/core/sessions/launch.mjs +4 -0
- package/lib/discover.mjs +95 -231
- package/lib/integrations/dsh-collaboration.mjs +9 -2
- package/lib/integrations/native/claude.mjs +6 -0
- package/lib/integrations/native/codex.mjs +4 -2
- package/lib/integrations/native/dsh-acp.mjs +1 -1
- package/lib/integrations/native/jsonrpc.mjs +2 -2
- package/lib/mcp/scope.mjs +50 -8
- package/lib/mcp/tools.mjs +15 -2
- package/lib/process/pinned-cwd.mjs +64 -0
- package/lib/process/pinned-guidance.mjs +1 -0
- package/lib/process/pinned-project-state.mjs +1 -0
- package/lib/process/tmux-cwd-handoff.mjs +277 -0
- package/lib/runtime/native/cli-submissions.mjs +220 -0
- package/lib/runtime/native/client.mjs +7 -1
- package/lib/runtime/native/launcher.mjs +20 -4
- package/lib/runtime/native/service.mjs +143 -50
- package/lib/runtime/native/store.mjs +303 -14
- package/lib/runtime/private-state.mjs +435 -24
- package/lib/runtime/project-path.mjs +53 -1
- package/lib/runtime/project-trust.mjs +6 -2
- package/lib/runtime/state-migration.mjs +66 -13
- package/lib/shared/redact.mjs +4 -1
- package/lib/terminal/tmux.mjs +13 -2
- package/lib/ui/help.mjs +1 -0
- package/lib/web/codex-app-server.mjs +3 -1
- package/lib/web/codex-sessions.mjs +68 -8
- package/lib/web/context-files.mjs +194 -41
- package/lib/web/http-routes.mjs +43 -12
- package/lib/web/native-sessions.mjs +3 -0
- package/lib/web/project-contexts.mjs +27 -5
- package/lib/web/project-files.mjs +108 -26
- package/lib/web/pty-sessions.mjs +15 -2
- package/lib/web/session-serialize.mjs +6 -1
- package/lib/web/startup.mjs +3 -1
- package/lib/web/tmux-sessions.mjs +153 -50
- package/lib/web/ui-interactions.mjs +7 -4
- package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
- package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
- package/native/cwd-handoff/README.md +45 -0
- package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
- package/package.json +8 -2
- package/scripts/build-cwd-handoff.mjs +47 -0
- package/scripts/pack-cwd-handoff.mjs +27 -0
- package/scripts/regression.mjs +113 -7
- package/scripts/verify-cwd-handoff-package.mjs +60 -0
- package/scripts/web-workbench-acceptance.mjs +79 -1
- package/scripts/web-workbench-installed-acceptance.mjs +11 -1
|
@@ -1,23 +1,86 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
3
4
|
import { CliError } from '../shared/errors.mjs';
|
|
5
|
+
import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
6
|
+
import { privateComponent, PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE,
|
|
7
|
+
runPinnedDirectoryWorker } from './project-files.mjs';
|
|
4
8
|
|
|
5
|
-
const
|
|
6
|
-
const ignoredName = name => ignored.has(name) || name === '.env' || name.startsWith('.env.');
|
|
9
|
+
const contextIgnored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
|
|
7
10
|
|
|
8
|
-
function
|
|
11
|
+
function changed() {
|
|
12
|
+
return new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed while listing files');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function assertBoundRoot(rootIdentity) {
|
|
9
16
|
if (!rootIdentity) return;
|
|
10
17
|
rootIdentity.assertUnchanged();
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameDirectory(left, right) {
|
|
21
|
+
return left.isDirectory() && right.isDirectory() && left.dev === right.dev &&
|
|
22
|
+
left.ino === right.ino && left.mode === right.mode && left.size === right.size &&
|
|
23
|
+
left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Windows cannot use the POSIX pinned helper, so keep the existing read-only
|
|
27
|
+
// traversal there. Its transient ancestor-replacement risk is not closed here.
|
|
28
|
+
async function listContextFilesByPath(root, query, { limit, maxEntries, timeBudgetMs, now, rootIdentity }) {
|
|
29
|
+
assertBoundRoot(rootIdentity);
|
|
30
|
+
const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
|
|
31
|
+
const directorySnapshots = [];
|
|
32
|
+
if (rootIdentity) {
|
|
33
|
+
const parent = path.dirname(base);
|
|
34
|
+
directorySnapshots.push({ directory: parent, stat: await fs.lstat(parent, { bigint: true }) });
|
|
35
|
+
const initial = await fs.lstat(base, { bigint: true });
|
|
36
|
+
assertBoundRoot(rootIdentity);
|
|
37
|
+
if (rootIdentity.identity.dev !== initial.dev.toString() ||
|
|
38
|
+
rootIdentity.identity.ino !== initial.ino.toString() ||
|
|
39
|
+
rootIdentity.identity.birthtimeNs !== (initial.birthtimeNs?.toString() || null)) throw changed();
|
|
40
|
+
directorySnapshots.push({ directory: base, stat: initial });
|
|
15
41
|
}
|
|
42
|
+
const exact = await exactContextFile(base, query, rootIdentity);
|
|
43
|
+
if (exact) paths.push(exact);
|
|
44
|
+
let seen = 0, truncated = false;
|
|
45
|
+
const deadline = now() + timeBudgetMs;
|
|
46
|
+
outer: while (pending.length) {
|
|
47
|
+
if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
|
|
48
|
+
assertBoundRoot(rootIdentity);
|
|
49
|
+
const relative = pending.shift(), directory = path.join(base, relative);
|
|
50
|
+
try {
|
|
51
|
+
const before = await fs.lstat(directory, { bigint: true });
|
|
52
|
+
if (before.isSymbolicLink() || !before.isDirectory()) continue;
|
|
53
|
+
if (rootIdentity) directorySnapshots.push({ directory, stat: before });
|
|
54
|
+
const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
|
|
55
|
+
if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
|
|
56
|
+
const handle = await fs.opendir(directory);
|
|
57
|
+
for await (const item of handle) {
|
|
58
|
+
seen++;
|
|
59
|
+
if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
|
|
60
|
+
if (item.isSymbolicLink() || contextIgnored.has(item.name) || privateComponent(item.name) ||
|
|
61
|
+
/[\\:\x00-\x1f\x7f]/.test(item.name)) continue;
|
|
62
|
+
const name = relative ? relative + '/' + item.name : item.name;
|
|
63
|
+
if (item.isDirectory()) pending.push(name);
|
|
64
|
+
else if (item.isFile() && name !== exact && name.toLocaleLowerCase().includes(needle)) {
|
|
65
|
+
paths.push(name);
|
|
66
|
+
if (paths.length >= limit) { truncated = true; break outer; }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (rootIdentity && !sameDirectory(before, await fs.lstat(directory, { bigint: true }))) throw changed();
|
|
70
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
|
|
71
|
+
}
|
|
72
|
+
assertBoundRoot(rootIdentity);
|
|
73
|
+
if (rootIdentity) {
|
|
74
|
+
for (const { directory, stat } of directorySnapshots) {
|
|
75
|
+
if (!sameDirectory(stat, await fs.lstat(directory, { bigint: true }))) throw changed();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { paths: paths.sort(), truncated };
|
|
16
79
|
}
|
|
17
80
|
|
|
18
81
|
async function exactContextFile(base, query, rootIdentity) {
|
|
19
82
|
const parts = query.split('/');
|
|
20
|
-
if (!query || parts.some(part => !part || part === '.' ||
|
|
83
|
+
if (!query || parts.some(part => !part || part === '.' || contextIgnored.has(part) || privateComponent(part))) return null;
|
|
21
84
|
let directory = base;
|
|
22
85
|
try {
|
|
23
86
|
for (let index = 0; index < parts.length; index++) {
|
|
@@ -26,7 +89,7 @@ async function exactContextFile(base, query, rootIdentity) {
|
|
|
26
89
|
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return null;
|
|
27
90
|
const resolved = await fs.realpath(target), relative = path.relative(base, resolved);
|
|
28
91
|
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
|
|
29
|
-
relative.split(path.sep).some(
|
|
92
|
+
relative.split(path.sep).some(part => contextIgnored.has(part) || privateComponent(part))) return null;
|
|
30
93
|
// Recheck the named entry after resolving it and discard any observed
|
|
31
94
|
// link or entry replacement before offering the filename.
|
|
32
95
|
const confirmed = await fs.lstat(target);
|
|
@@ -38,6 +101,103 @@ async function exactContextFile(base, query, rootIdentity) {
|
|
|
38
101
|
return null;
|
|
39
102
|
}
|
|
40
103
|
|
|
104
|
+
function contextListWorker() {
|
|
105
|
+
const fs = require('node:fs');
|
|
106
|
+
const { query, limit, maxEntries, timeBudgetMs } = JSON.parse(process.argv[1]);
|
|
107
|
+
const deadline = Date.now() + timeBudgetMs;
|
|
108
|
+
const root = fs.statSync('.', { bigint: true });
|
|
109
|
+
const same = (left, right) => left.isDirectory() && right.isDirectory() &&
|
|
110
|
+
left.dev === right.dev && left.ino === right.ino && left.birthtimeNs === right.birthtimeNs;
|
|
111
|
+
const skippable = new Set(['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM', 'ELOOP']);
|
|
112
|
+
const changed = () => { const error = new Error('Directory identity changed'); error.code = 'PROJECT_PATH_CHANGED'; return error; };
|
|
113
|
+
|
|
114
|
+
function leave(held) {
|
|
115
|
+
for (let index = held.length - 1; index >= 0; index--) {
|
|
116
|
+
process.chdir('..');
|
|
117
|
+
const current = fs.statSync('.', { bigint: true });
|
|
118
|
+
const expected = index ? held[index - 1].stat : root;
|
|
119
|
+
fs.closeSync(held[index].fd);
|
|
120
|
+
if (!same(current, expected)) throw changed();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function enter(relative) {
|
|
125
|
+
const held = [];
|
|
126
|
+
for (const part of relative ? relative.split('/') : []) {
|
|
127
|
+
let fd;
|
|
128
|
+
try { fd = fs.openSync(part, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW); }
|
|
129
|
+
catch (error) {
|
|
130
|
+
leave(held);
|
|
131
|
+
if (skippable.has(error.code)) return null;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
let stat;
|
|
135
|
+
try { stat = fs.fstatSync(fd, { bigint: true }); }
|
|
136
|
+
catch (error) { fs.closeSync(fd); leave(held); throw error; }
|
|
137
|
+
if (!stat.isDirectory()) { fs.closeSync(fd); leave(held); return null; }
|
|
138
|
+
try { process.chdir(part); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
fs.closeSync(fd); leave(held);
|
|
141
|
+
if (skippable.has(error.code)) return null;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
// A rename between no-follow open and chdir must not let us inspect B.
|
|
145
|
+
if (!same(stat, fs.statSync('.', { bigint: true }))) throw changed();
|
|
146
|
+
held.push({ fd, stat });
|
|
147
|
+
}
|
|
148
|
+
return held;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function exactPath() {
|
|
152
|
+
const parts = query.split('/');
|
|
153
|
+
if (!query || parts.some(part => !part || part === '.' || contextIgnored.has(part) || privateComponent(part))) return null;
|
|
154
|
+
const held = enter(parts.slice(0, -1).join('/'));
|
|
155
|
+
if (held === null) return null;
|
|
156
|
+
try {
|
|
157
|
+
const entry = fs.lstatSync(parts.at(-1));
|
|
158
|
+
return entry.isFile() && !entry.isSymbolicLink() ? query : null;
|
|
159
|
+
} catch (error) { if (skippable.has(error.code)) return null; throw error; }
|
|
160
|
+
finally { leave(held); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pending = [''], paths = [], needle = query.toLocaleLowerCase();
|
|
164
|
+
const exact = exactPath();
|
|
165
|
+
if (exact) paths.push(exact);
|
|
166
|
+
let seen = 0, truncated = false, stop = false;
|
|
167
|
+
try {
|
|
168
|
+
while (pending.length && !stop) {
|
|
169
|
+
if (Date.now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
|
|
170
|
+
const relative = pending.shift(), held = enter(relative);
|
|
171
|
+
if (held === null) continue;
|
|
172
|
+
let directory;
|
|
173
|
+
try {
|
|
174
|
+
directory = fs.opendirSync('.');
|
|
175
|
+
for (let item; (item = directory.readSync()) !== null;) {
|
|
176
|
+
seen++;
|
|
177
|
+
if (seen > maxEntries || Date.now() >= deadline) { truncated = true; stop = true; break; }
|
|
178
|
+
if (item.isSymbolicLink() || contextIgnored.has(item.name) || privateComponent(item.name) ||
|
|
179
|
+
/[\\:\x00-\x1f\x7f]/.test(item.name)) continue;
|
|
180
|
+
const name = relative ? relative + '/' + item.name : item.name;
|
|
181
|
+
if (item.isDirectory()) pending.push(name);
|
|
182
|
+
else if (item.isFile() && name !== exact && name.toLocaleLowerCase().includes(needle)) {
|
|
183
|
+
paths.push(name);
|
|
184
|
+
if (paths.length >= limit) { truncated = true; stop = true; break; }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (error) { if (!skippable.has(error.code)) throw error; }
|
|
188
|
+
finally { directory?.closeSync(); leave(held); }
|
|
189
|
+
}
|
|
190
|
+
process.stdout.write(JSON.stringify({ paths, truncated, exact }));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
process.stderr.write('HCC_DIRECTORY_WORKER_FAILED:' + (error.code || 'PROJECT_PATH_CHANGED') + '\n');
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const CONTEXT_LIST_WORKER_SOURCE = PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE +
|
|
198
|
+
`const contextIgnored = new Set(${JSON.stringify([...contextIgnored])});\n` +
|
|
199
|
+
`(${contextListWorker.toString()})()`;
|
|
200
|
+
|
|
41
201
|
/** List bounded project-relative filenames only. Never open or upload their contents. */
|
|
42
202
|
export async function listContextFiles(root, query = '', { limit = 80, maxEntries = 10000, timeBudgetMs = 150, now = Date.now, rootIdentity = null } = {}) {
|
|
43
203
|
if (typeof query !== 'string' || query.length > 256 || /[\0\r\n]/.test(query) || path.isAbsolute(query) || query.includes('\\') || query.split('/').includes('..')) {
|
|
@@ -46,37 +206,30 @@ export async function listContextFiles(root, query = '', { limit = 80, maxEntrie
|
|
|
46
206
|
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(timeBudgetMs) || timeBudgetMs < 0 || typeof now !== 'function') {
|
|
47
207
|
throw new TypeError('Invalid context file listing budget');
|
|
48
208
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
let seen = 0, truncated = false;
|
|
57
|
-
const deadline = now() + timeBudgetMs;
|
|
58
|
-
outer: while (pending.length) {
|
|
59
|
-
if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
|
|
60
|
-
assertBoundRoot(rootIdentity);
|
|
61
|
-
const relative = pending.shift(), directory = path.join(base, relative);
|
|
209
|
+
if (process.platform === 'win32') return listContextFilesByPath(root, query,
|
|
210
|
+
{ limit, maxEntries, timeBudgetMs, now, rootIdentity });
|
|
211
|
+
const ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
|
|
212
|
+
const identity = rootIdentity || ownedIdentity;
|
|
213
|
+
try {
|
|
214
|
+
assertBoundRoot(identity);
|
|
215
|
+
let result;
|
|
62
216
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return { paths: paths.sort(), truncated };
|
|
217
|
+
result = await runPinnedDirectoryWorker(identity.canonical, identity,
|
|
218
|
+
CONTEXT_LIST_WORKER_SOURCE, { query, limit, maxEntries, timeBudgetMs });
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (['PROJECT_FILE_CHANGED', 'PROJECT_PATH_CHANGED'].includes(error.code)) throw changed();
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
assertBoundRoot(identity);
|
|
224
|
+
const paths = [];
|
|
225
|
+
// Apply the listing budget inside the pinned worker; bootstrap latency
|
|
226
|
+
// must not discard names that the bounded scan already found.
|
|
227
|
+
const deadline = now() + timeBudgetMs;
|
|
228
|
+
let truncated = result.truncated;
|
|
229
|
+
for (const file of result.paths) {
|
|
230
|
+
if (file !== result.exact && now() >= deadline) { truncated = true; break; }
|
|
231
|
+
paths.push(file);
|
|
232
|
+
}
|
|
233
|
+
return { paths: paths.sort(), truncated };
|
|
234
|
+
} finally { ownedIdentity?.release(); }
|
|
82
235
|
}
|
package/lib/web/http-routes.mjs
CHANGED
|
@@ -13,8 +13,7 @@ import { createAgentDefaults } from './agent-defaults.mjs';
|
|
|
13
13
|
import { listProjectFiles, previewProjectFile, inspectProjectFileStatus, projectFileErrorStatus } from './project-files.mjs';
|
|
14
14
|
import { saveProjectFile, uploadProjectFile } from './project-file-write.mjs';
|
|
15
15
|
import { projectRecord } from '../runtime/projects.mjs';
|
|
16
|
-
import {
|
|
17
|
-
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
16
|
+
import { prevalidateProjectDatabaseLocation } from '../runtime/project-path.mjs';
|
|
18
17
|
import { writeRuntime } from '../runtime/state.mjs';
|
|
19
18
|
import { normalizeStateResources } from '../ui/state-render.mjs';
|
|
20
19
|
import { requestUrl, runtimeBaseUrl } from '../web/runtime.mjs';
|
|
@@ -31,7 +30,7 @@ import { scheduleResponseShutdown } from './shutdown-response.mjs';
|
|
|
31
30
|
// even when the dispatcher ultimately returns 404. Null means the matching
|
|
32
31
|
// dispatcher branch handles its own method error.
|
|
33
32
|
const projectApiPaths = new Map([
|
|
34
|
-
['/api/agent-defaults',
|
|
33
|
+
['/api/agent-defaults', ['GET', 'PUT']],
|
|
35
34
|
['/api/files/tree', ['GET']], ['/api/files/preview', ['GET']],
|
|
36
35
|
['/api/files/status', ['GET']], ['/api/files/content', ['PUT']], ['/api/files/upload', ['POST']],
|
|
37
36
|
['/api/context/files', ['GET']],
|
|
@@ -41,13 +40,18 @@ const projectApiPaths = new Map([
|
|
|
41
40
|
['/api/sessions', ['GET', 'POST']], ['/api/sessions/attach', ['POST']],
|
|
42
41
|
['/api/native/history', ['GET']], ['/api/codex/threads', ['GET']]
|
|
43
42
|
]);
|
|
43
|
+
const peerReadActions = new Set(['status', 'state', 'inbox']);
|
|
44
|
+
const peerWriteActions = new Set([
|
|
45
|
+
'task-next', 'task-takeover', 'lock-acquire', 'lock-release', 'heartbeat', 'register'
|
|
46
|
+
]);
|
|
44
47
|
const projectApiPatterns = [
|
|
45
|
-
[/^\/api\/
|
|
46
|
-
[/^\/api\/native\/history\/[^/]
|
|
48
|
+
[/^\/api\/native\/history\/[^/]+$/, ['GET']],
|
|
49
|
+
[/^\/api\/native\/history\/[^/]+\/resume$/, ['POST']],
|
|
47
50
|
[/^\/api\/sessions\/[^/]+\/native\/([^/]+)$/, null, {
|
|
48
51
|
GET: ['state', 'account'], POST: ['send', 'interrupt', 'close', 'respond']
|
|
49
52
|
}],
|
|
50
|
-
[/^\/api\/codex\/threads\/[^/]
|
|
53
|
+
[/^\/api\/codex\/threads\/[^/]+$/, ['GET']],
|
|
54
|
+
[/^\/api\/codex\/threads\/[^/]+\/fork$/, ['POST']],
|
|
51
55
|
[/^\/api\/sessions\/[^/]+\/results$/, ['GET', 'POST']],
|
|
52
56
|
[/^\/api\/sessions\/[^/]+\/codex\/([^/]+)$/, null, {
|
|
53
57
|
GET: ['state', 'read', 'account'], POST: ['turn', 'steer', 'interrupt', 'approve']
|
|
@@ -56,11 +60,25 @@ const projectApiPatterns = [
|
|
|
56
60
|
[/^\/api\/detected\/[^/]+\/(?:msg|stop|restart)$/, ['POST']]
|
|
57
61
|
];
|
|
58
62
|
|
|
63
|
+
function peerApiActionKind(pathname) {
|
|
64
|
+
const match = pathname.match(/^\/api\/peers\/[^/]+\/actions\/([^/]+)$/);
|
|
65
|
+
if (!match) return null;
|
|
66
|
+
try {
|
|
67
|
+
const action = decodeURIComponent(match[1]).replace(/_/g, '-');
|
|
68
|
+
if (peerReadActions.has(action)) return 'read';
|
|
69
|
+
if (peerWriteActions.has(action)) return 'write';
|
|
70
|
+
} catch {}
|
|
71
|
+
return 'unknown';
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
function projectApiRouteExists(method, pathname) {
|
|
60
75
|
if (projectApiPaths.has(pathname)) {
|
|
61
76
|
const methods = projectApiPaths.get(pathname);
|
|
62
77
|
return methods === null || methods.includes(method);
|
|
63
78
|
}
|
|
79
|
+
const peerAction = peerApiActionKind(pathname);
|
|
80
|
+
if (peerAction !== null) return peerAction === 'read' ? method === 'GET' :
|
|
81
|
+
peerAction === 'write' && method === 'POST';
|
|
64
82
|
return projectApiPatterns.some(([pattern, methods, actions]) => {
|
|
65
83
|
const match = pathname.match(pattern);
|
|
66
84
|
if (!match || methods !== null && !methods.includes(method)) return false;
|
|
@@ -240,10 +258,23 @@ export function createHttpRoutes(deps) {
|
|
|
240
258
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
|
241
259
|
return;
|
|
242
260
|
}
|
|
243
|
-
|
|
261
|
+
// Dynamic IDs can be malformed even when the route shape and action
|
|
262
|
+
// match. Validate the whole pathname before resolving a project.
|
|
263
|
+
let pathEncodingValid = true;
|
|
264
|
+
try { decodeURIComponent(url.pathname); } catch { pathEncodingValid = false; }
|
|
265
|
+
if (!pathEncodingValid || !projectApiRouteExists(req.method, url.pathname)) {
|
|
266
|
+
const peerAction = pathEncodingValid ? peerApiActionKind(url.pathname) : null;
|
|
267
|
+
if (peerAction === 'read' || peerAction === 'write') {
|
|
268
|
+
sendJson(res, 405, { ok: false, error: { code: 'METHOD_NOT_ALLOWED',
|
|
269
|
+
message: peerAction === 'read' ? 'Use GET for read-only peer actions' : 'Use POST for mutating peer actions' } });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (url.pathname === '/api/agent-defaults') {
|
|
273
|
+
throw new CliError('BAD_REQUEST', 'Agent defaults support GET and PUT only');
|
|
274
|
+
}
|
|
244
275
|
// The legacy Codex account-control POSTs have a stable BAD_REQUEST
|
|
245
276
|
// contract. Reject them here without resolving an arbitrary project.
|
|
246
|
-
if (legacyForbiddenCodexAction(req.method, url.pathname)) {
|
|
277
|
+
if (pathEncodingValid && legacyForbiddenCodexAction(req.method, url.pathname)) {
|
|
247
278
|
throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
|
|
248
279
|
}
|
|
249
280
|
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
|
@@ -259,11 +290,10 @@ export function createHttpRoutes(deps) {
|
|
|
259
290
|
// Reject an outside DB before resolving the body-selected root, which
|
|
260
291
|
// can create its managed state directory and record registry activity.
|
|
261
292
|
if (projectInput?.db) {
|
|
262
|
-
|
|
293
|
+
prevalidateProjectDatabaseLocation({
|
|
263
294
|
root: projectInput.root || url.searchParams.get('root') ||
|
|
264
295
|
url.searchParams.get('project') || req.headers['x-hcc-root'] || ctx.root,
|
|
265
|
-
db: projectInput.db
|
|
266
|
-
createStateDir: false
|
|
296
|
+
db: projectInput.db
|
|
267
297
|
});
|
|
268
298
|
}
|
|
269
299
|
const projectUrl = projectInput?.root && !url.searchParams.has('root') &&
|
|
@@ -325,7 +355,8 @@ export function createHttpRoutes(deps) {
|
|
|
325
355
|
const requestedRoot = input.root || reqCtx.root;
|
|
326
356
|
const selected = resolveWebProjectContext(
|
|
327
357
|
requestedRoot,
|
|
328
|
-
input.db ||
|
|
358
|
+
input.db || null,
|
|
359
|
+
{ explicitSelection: true }
|
|
329
360
|
);
|
|
330
361
|
let projectCtx;
|
|
331
362
|
try {
|
|
@@ -11,6 +11,7 @@ import { ensureNativeRuntime } from '../runtime/native/launcher.mjs';
|
|
|
11
11
|
import { readNativeHistory, readNativeWorkerHistory } from '../runtime/native/history.mjs';
|
|
12
12
|
import { parseNativeCwdIdentity } from '../runtime/native/store.mjs';
|
|
13
13
|
import { captureSelectedCwdSnapshot, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
14
|
+
import { assertPinnedSessionLaunchAllowed } from '../process/pinned-cwd.mjs';
|
|
14
15
|
import { createAgentDefaults } from './agent-defaults.mjs';
|
|
15
16
|
|
|
16
17
|
const PROVIDERS = new Set(['codex', 'claude', 'dsh']);
|
|
@@ -148,6 +149,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
148
149
|
typeof input.sessionId !== 'string' || !input.sessionId || input.sessionId.length > 512) {
|
|
149
150
|
throw new CliError('BAD_REQUEST', 'Restore requires confirmed:true and the saved owner and sessionId');
|
|
150
151
|
}
|
|
152
|
+
assertPinnedSessionLaunchAllowed();
|
|
151
153
|
const key = sessionKey(ctx, peer);
|
|
152
154
|
if (stopped) throw new CliError('NATIVE_WEB_DISCONNECTED', 'Web is stopping');
|
|
153
155
|
if (creating.has(key)) throw new CliError('NATIVE_WORKER_EXISTS', 'This worker is already being opened', { peer });
|
|
@@ -206,6 +208,7 @@ export function createNativeSessions({ sessions, sessionKey, connectWebProject,
|
|
|
206
208
|
input.transport !== 'native' || (input.kind !== undefined && !PROVIDERS.has(input.kind))) {
|
|
207
209
|
throw new CliError('BAD_REQUEST', 'Native creation accepts only transport, kind, id, cwd and model; use provider codex, claude or dsh');
|
|
208
210
|
}
|
|
211
|
+
assertPinnedSessionLaunchAllowed();
|
|
209
212
|
const ctx = input.projectCtx;
|
|
210
213
|
// Resolve before asynchronous daemon startup; explicit null clears a saved model.
|
|
211
214
|
input = resolveNativeDefaults(ctx, input);
|
|
@@ -8,7 +8,8 @@ import path from 'node:path';
|
|
|
8
8
|
import WebSocket from 'ws';
|
|
9
9
|
import { CliError } from '../shared/errors.mjs';
|
|
10
10
|
import { contextForProject, projectDbPath } from '../runtime/paths.mjs';
|
|
11
|
-
import { resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
11
|
+
import { prevalidateProjectDatabaseLocation, resolveProjectDatabase } from '../runtime/project-path.mjs';
|
|
12
|
+
import { provisionPrivateProjectGeneration } from '../runtime/private-state.mjs';
|
|
12
13
|
import { assertSelectedCwdSnapshot, captureSelectedCwdIdentity, sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
13
14
|
import {
|
|
14
15
|
projectRecord,
|
|
@@ -33,6 +34,7 @@ export function createProjectContexts(deps) {
|
|
|
33
34
|
} = deps;
|
|
34
35
|
|
|
35
36
|
const projectContexts = new Map();
|
|
37
|
+
const retiredProjectContexts = new Set();
|
|
36
38
|
const MAX_IDLE_PROJECT_DESCRIPTORS = 32;
|
|
37
39
|
|
|
38
40
|
function changedProject() {
|
|
@@ -79,6 +81,11 @@ export function createProjectContexts(deps) {
|
|
|
79
81
|
retainedIdle += 1;
|
|
80
82
|
if (retainedIdle > maxIdle) binding.dropDescriptor();
|
|
81
83
|
}
|
|
84
|
+
for (const project of retiredProjectContexts) {
|
|
85
|
+
if (hasLiveManagedSession(project.rootIdentity)) continue;
|
|
86
|
+
project.rootIdentity?.release();
|
|
87
|
+
retiredProjectContexts.delete(project);
|
|
88
|
+
}
|
|
82
89
|
}
|
|
83
90
|
|
|
84
91
|
function retainProjectContextDescriptor(projectCtx) {
|
|
@@ -156,7 +163,10 @@ export function createProjectContexts(deps) {
|
|
|
156
163
|
dropIdleProjectContextDescriptors(retained);
|
|
157
164
|
return existing;
|
|
158
165
|
}
|
|
159
|
-
if (previous && previous !== retained)
|
|
166
|
+
if (previous && previous !== retained) {
|
|
167
|
+
if (hasLiveManagedSession(previous)) retiredProjectContexts.add(existing);
|
|
168
|
+
else previous.release();
|
|
169
|
+
}
|
|
160
170
|
projectContexts.set(normalized.root, normalized);
|
|
161
171
|
dropIdleProjectContextDescriptors(retained);
|
|
162
172
|
return normalized;
|
|
@@ -185,7 +195,7 @@ export function createProjectContexts(deps) {
|
|
|
185
195
|
return rows;
|
|
186
196
|
}
|
|
187
197
|
|
|
188
|
-
function resolveWebProjectContext(root, db) {
|
|
198
|
+
function resolveWebProjectContext(root, db, { explicitSelection = false } = {}) {
|
|
189
199
|
const selected = captureSelectedCwdIdentity(root);
|
|
190
200
|
let binding = selected;
|
|
191
201
|
try {
|
|
@@ -194,6 +204,16 @@ export function createProjectContexts(deps) {
|
|
|
194
204
|
// pathname. The alias must still be unchanged throughout selection.
|
|
195
205
|
if (selected.requested !== selected.canonical) binding = captureSelectedCwdIdentity(selected.canonical);
|
|
196
206
|
selected.assertUnchanged();
|
|
207
|
+
if (db) {
|
|
208
|
+
prevalidateProjectDatabaseLocation({ root: selected.requested, db });
|
|
209
|
+
selected.assertUnchanged();
|
|
210
|
+
binding.assertUnchanged();
|
|
211
|
+
}
|
|
212
|
+
if (explicitSelection) {
|
|
213
|
+
provisionPrivateProjectGeneration(binding.canonical, { expectedIdentity: binding });
|
|
214
|
+
selected.assertUnchanged();
|
|
215
|
+
binding.assertUnchanged();
|
|
216
|
+
}
|
|
197
217
|
// Preserve explicit legacy DB spellings under a selected alias while
|
|
198
218
|
// resolving state against the captured canonical directory only.
|
|
199
219
|
const selectedLegacy = path.join(selected.requested, '.hello-cc');
|
|
@@ -202,7 +222,7 @@ export function createProjectContexts(deps) {
|
|
|
202
222
|
!relativeDb.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeDb);
|
|
203
223
|
const stableDb = underSelectedLegacy
|
|
204
224
|
? path.join(binding.canonical, '.hello-cc', relativeDb)
|
|
205
|
-
: db;
|
|
225
|
+
: db || projectDbPath(binding.canonical);
|
|
206
226
|
const first = resolveProjectDatabase({ root: binding.canonical, db: stableDb, createStateDir: true });
|
|
207
227
|
selected.assertUnchanged();
|
|
208
228
|
binding.assertUnchanged();
|
|
@@ -281,7 +301,7 @@ export function createProjectContexts(deps) {
|
|
|
281
301
|
}
|
|
282
302
|
carried = captureSelectedCwdIdentity(requestedRoot, expected);
|
|
283
303
|
}
|
|
284
|
-
resolved = resolveWebProjectContext(requestedRoot, requestedDb
|
|
304
|
+
resolved = resolveWebProjectContext(requestedRoot, requestedDb, { explicitSelection });
|
|
285
305
|
if (carried && !sameSelectedCwdIdentity(carried, resolved.rootIdentity)) throw changedProject();
|
|
286
306
|
const remembered = rememberProject(resolved, { activity: true });
|
|
287
307
|
resolved = null;
|
|
@@ -346,7 +366,9 @@ export function createProjectContexts(deps) {
|
|
|
346
366
|
|
|
347
367
|
function releaseProjectContexts() {
|
|
348
368
|
for (const project of projectContexts.values()) project.rootIdentity?.release();
|
|
369
|
+
for (const project of retiredProjectContexts) project.rootIdentity?.release();
|
|
349
370
|
projectContexts.clear();
|
|
371
|
+
retiredProjectContexts.clear();
|
|
350
372
|
}
|
|
351
373
|
|
|
352
374
|
function knownPeerIds(projectCtx) {
|