@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
|
@@ -2,12 +2,31 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { CliError } from '../shared/errors.mjs';
|
|
5
|
+
import { projectStateDir, legacyProjectStateDir } from './paths.mjs';
|
|
6
|
+
import { ensurePrivateProjectStateDir, inspectPrivateProjectAuthority } from './private-state.mjs';
|
|
7
|
+
import { unsafeDirectoryAcl } from './project-trust.mjs';
|
|
5
8
|
|
|
6
9
|
function pathIsWithin(parent, candidate) {
|
|
7
10
|
const relative = path.relative(parent, candidate);
|
|
8
11
|
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
9
12
|
}
|
|
10
13
|
|
|
14
|
+
function canonicalCandidateFromExistingParent(candidate) {
|
|
15
|
+
let parent = path.dirname(candidate);
|
|
16
|
+
const missing = [];
|
|
17
|
+
for (;;) {
|
|
18
|
+
try {
|
|
19
|
+
return path.join(fs.realpathSync(parent), ...missing.reverse(), path.basename(candidate));
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error?.code !== 'ENOENT') return null;
|
|
22
|
+
const next = path.dirname(parent);
|
|
23
|
+
if (next === parent) return null;
|
|
24
|
+
missing.push(path.basename(parent));
|
|
25
|
+
parent = next;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
11
30
|
function projectPathForbidden(message) {
|
|
12
31
|
return new CliError('PROJECT_PATH_FORBIDDEN', message);
|
|
13
32
|
}
|
|
@@ -23,7 +42,7 @@ function canonicalProjectRoot(root) {
|
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
|
|
26
|
-
function privateDirectory(directory, description) {
|
|
45
|
+
function privateDirectory(directory, description, { tighten = true } = {}) {
|
|
27
46
|
let stat;
|
|
28
47
|
try { stat = fs.lstatSync(directory); }
|
|
29
48
|
catch { throw projectPathForbidden(`Cannot inspect ${description}: ${directory}`); }
|
|
@@ -37,11 +56,15 @@ function privateDirectory(directory, description) {
|
|
|
37
56
|
fd = fs.openSync(directory, fs.constants.O_RDONLY |
|
|
38
57
|
(fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
|
|
39
58
|
const opened = fs.fstatSync(fd);
|
|
40
|
-
if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino
|
|
59
|
+
if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino ||
|
|
60
|
+
unsafeDirectoryAcl(directory, opened)) {
|
|
41
61
|
throw projectPathForbidden(`${description} changed during validation: ${directory}`);
|
|
42
62
|
}
|
|
43
63
|
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) {
|
|
44
|
-
fs.fchmodSync(fd, 0o700);
|
|
64
|
+
if (tighten) fs.fchmodSync(fd, 0o700);
|
|
65
|
+
else if ((opened.mode & 0o022) !== 0) {
|
|
66
|
+
throw projectPathForbidden(`${description} is writable by another user: ${directory}`);
|
|
67
|
+
}
|
|
45
68
|
}
|
|
46
69
|
const current = fs.lstatSync(directory);
|
|
47
70
|
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
@@ -57,7 +80,47 @@ function privateDirectory(directory, description) {
|
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
function ensureStateDirectory(root, createStateDir, secureExisting = createStateDir) {
|
|
60
|
-
const stateDir =
|
|
83
|
+
const stateDir = projectStateDir(root);
|
|
84
|
+
if (stateDir !== legacyProjectStateDir(root)) {
|
|
85
|
+
const authority = inspectPrivateProjectAuthority(root);
|
|
86
|
+
let privateEntryExists = false;
|
|
87
|
+
try { fs.lstatSync(stateDir); privateEntryExists = true; }
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error?.code !== 'ENOENT') {
|
|
90
|
+
throw projectPathForbidden(`Cannot inspect private project state: ${stateDir}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A crash can leave a pending marker with an empty directory or a valid
|
|
94
|
+
// manifest not yet promoted to fresh. Older private stores may have no
|
|
95
|
+
// sibling marker. Finish either binding before returning any existing
|
|
96
|
+
// state, including during a read-only request.
|
|
97
|
+
const finishBinding = privateEntryExists &&
|
|
98
|
+
(authority === null || authority === 'pending');
|
|
99
|
+
const existing = ensurePrivateProjectStateDir(root, { create: finishBinding });
|
|
100
|
+
if (existing) return { stateDir, exists: true };
|
|
101
|
+
if (authority === 'fresh' || authority === 'migrated') {
|
|
102
|
+
throw new CliError('STATE_AUTHORITY_MISSING',
|
|
103
|
+
`Previously established private project state is missing: ${stateDir}. Restore it from backup; ` +
|
|
104
|
+
'if the legacy source still exists, stop all old writers and run ' +
|
|
105
|
+
'hcc --root DIR migrate-state --offline --yes; otherwise run ' +
|
|
106
|
+
'hcc --root DIR uninstall --purge --yes to explicitly reset the missing state.');
|
|
107
|
+
}
|
|
108
|
+
const legacy = legacyProjectStateDir(root);
|
|
109
|
+
let legacyStat = null;
|
|
110
|
+
try { legacyStat = fs.lstatSync(legacy); }
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (error?.code !== 'ENOENT') throw projectPathForbidden(`Cannot inspect legacy project state: ${legacy}`);
|
|
113
|
+
}
|
|
114
|
+
if (legacyStat && (legacyStat.isSymbolicLink() || !legacyStat.isDirectory())) {
|
|
115
|
+
throw projectPathForbidden(`Legacy project state is not a real directory: ${legacy}`);
|
|
116
|
+
}
|
|
117
|
+
if (legacyStat) {
|
|
118
|
+
throw new CliError('STATE_MIGRATION_REQUIRED',
|
|
119
|
+
`Legacy project state requires an offline migration before use: ${legacy}`);
|
|
120
|
+
}
|
|
121
|
+
const secured = createStateDir ? ensurePrivateProjectStateDir(root, { create: true }) : null;
|
|
122
|
+
return { stateDir, exists: Boolean(secured) };
|
|
123
|
+
}
|
|
61
124
|
let stateStat;
|
|
62
125
|
try {
|
|
63
126
|
stateStat = fs.lstatSync(stateDir);
|
|
@@ -79,9 +142,7 @@ function ensureStateDirectory(root, createStateDir, secureExisting = createState
|
|
|
79
142
|
throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
|
|
80
143
|
}
|
|
81
144
|
try {
|
|
82
|
-
const canonical = secureExisting
|
|
83
|
-
? privateDirectory(stateDir, 'Project state directory')
|
|
84
|
-
: fs.realpathSync(stateDir);
|
|
145
|
+
const canonical = privateDirectory(stateDir, 'Project state directory', { tighten: secureExisting });
|
|
85
146
|
if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
|
|
86
147
|
throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
|
|
87
148
|
}
|
|
@@ -121,7 +182,7 @@ function validateDatabaseParent(stateDir, db, createParents = false) {
|
|
|
121
182
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
122
183
|
throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
|
|
123
184
|
}
|
|
124
|
-
if (
|
|
185
|
+
if (privateDirectory(current, 'Database parent directory', { tighten: createParents }) !== current) {
|
|
125
186
|
throw projectPathForbidden(`Database parent directory changed: ${current}`);
|
|
126
187
|
}
|
|
127
188
|
}
|
|
@@ -164,16 +225,23 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
|
|
|
164
225
|
const requestedRoot = path.resolve(String(root || ''));
|
|
165
226
|
const canonicalRoot = canonicalProjectRoot(requestedRoot);
|
|
166
227
|
const rawDb = path.resolve(String(db || path.join(requestedRoot, '.hello-cc', 'mesh.db')));
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
228
|
+
const intendedStateDir = projectStateDir(canonicalRoot);
|
|
229
|
+
const requestedLegacyState = legacyProjectStateDir(requestedRoot);
|
|
230
|
+
const canonicalLegacyState = legacyProjectStateDir(canonicalRoot);
|
|
231
|
+
let requestedDb = pathIsWithin(requestedLegacyState, rawDb)
|
|
232
|
+
? path.join(intendedStateDir, path.relative(requestedLegacyState, rawDb))
|
|
233
|
+
: pathIsWithin(canonicalLegacyState, rawDb)
|
|
234
|
+
? path.join(intendedStateDir, path.relative(canonicalLegacyState, rawDb))
|
|
235
|
+
: rawDb;
|
|
171
236
|
if (!pathIsWithin(intendedStateDir, requestedDb)) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
237
|
+
// An explicit --db may be spelled through an alias of an ancestor such as
|
|
238
|
+
// macOS /var -> /private/var, and its nested DB parents may not exist yet.
|
|
239
|
+
// Canonicalize the nearest existing parent before containment checking;
|
|
240
|
+
// validateDatabaseParent still rejects any newly created symlink segment.
|
|
241
|
+
const canonicalCandidate = canonicalCandidateFromExistingParent(requestedDb);
|
|
242
|
+
if (canonicalCandidate && pathIsWithin(intendedStateDir, canonicalCandidate)) {
|
|
243
|
+
requestedDb = canonicalCandidate;
|
|
244
|
+
}
|
|
177
245
|
}
|
|
178
246
|
if (!pathIsWithin(intendedStateDir, requestedDb)) {
|
|
179
247
|
throw projectPathForbidden(`Database path must live under ${intendedStateDir}`);
|
|
@@ -191,13 +259,13 @@ export function resolveProjectDatabase({ root, db, createStateDir = false, creat
|
|
|
191
259
|
};
|
|
192
260
|
}
|
|
193
261
|
|
|
194
|
-
export function
|
|
262
|
+
export function ensurePrivateProjectStateSubdirectory(root, directoryName, { create = true } = {}) {
|
|
195
263
|
if (!directoryName || directoryName !== path.basename(directoryName) ||
|
|
196
264
|
directoryName === '.' || directoryName === '..') {
|
|
197
265
|
throw projectPathForbidden(`Invalid buffer directory name: ${directoryName}`);
|
|
198
266
|
}
|
|
199
267
|
const canonicalRoot = canonicalProjectRoot(root);
|
|
200
|
-
const state = ensureStateDirectory(canonicalRoot, create,
|
|
268
|
+
const state = ensureStateDirectory(canonicalRoot, create, create);
|
|
201
269
|
if (!state.exists) return null;
|
|
202
270
|
const { stateDir } = state;
|
|
203
271
|
const directory = path.join(stateDir, directoryName);
|
|
@@ -215,8 +283,12 @@ export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs'
|
|
|
215
283
|
throw projectPathForbidden(`Cannot inspect buffer directory: ${directory}`);
|
|
216
284
|
}
|
|
217
285
|
}
|
|
218
|
-
if (privateDirectory(directory, '
|
|
286
|
+
if (privateDirectory(directory, 'Project state subdirectory', { tighten: create }) !== directory) {
|
|
219
287
|
throw projectPathForbidden(`Buffer directory escapes project state directory: ${directory}`);
|
|
220
288
|
}
|
|
221
289
|
return directory;
|
|
222
290
|
}
|
|
291
|
+
|
|
292
|
+
export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', options = {}) {
|
|
293
|
+
return ensurePrivateProjectStateSubdirectory(root, directoryName, options);
|
|
294
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
// A private .hello-cc is only safe behind a pathname that another UID cannot
|
|
7
|
+
// rename. The ownership of the leaf alone is not sufficient: a writable
|
|
8
|
+
// non-sticky ancestor lets another UID replace its directory entry.
|
|
9
|
+
const aclCache = new Map();
|
|
10
|
+
|
|
11
|
+
export function unsafeDirectoryAcl(directory, stat = null) {
|
|
12
|
+
let identity = stat;
|
|
13
|
+
try { identity ||= fs.lstatSync(directory); }
|
|
14
|
+
catch { return true; }
|
|
15
|
+
const signature = `${identity.dev}:${identity.ino}:${identity.ctimeNs ?? identity.ctimeMs}`;
|
|
16
|
+
const cached = aclCache.get(directory);
|
|
17
|
+
if (cached?.signature === signature) return cached.unsafe;
|
|
18
|
+
const ls = fs.existsSync('/bin/ls') ? '/bin/ls'
|
|
19
|
+
: fs.existsSync('/usr/bin/ls') ? '/usr/bin/ls' : null;
|
|
20
|
+
if (!ls) return true;
|
|
21
|
+
const result = spawnSync(ls, process.platform === 'darwin'
|
|
22
|
+
? ['-lde', directory] : ['-ld', directory], {
|
|
23
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000,
|
|
24
|
+
env: { ...process.env, LC_ALL: 'C' }
|
|
25
|
+
});
|
|
26
|
+
if (result.status !== 0) return true;
|
|
27
|
+
const [first, ...entries] = result.stdout.trim().split('\n');
|
|
28
|
+
const permissions = first?.trim().split(/\s+/, 1)[0];
|
|
29
|
+
if (!/^[-dlcbps][rwxStTs-]{9}[+@.]?$/.test(permissions)) return true;
|
|
30
|
+
// A deny-only macOS ACL cannot grant another UID the ability to rebind a
|
|
31
|
+
// directory entry. Unknown or allow ACL entries use the private fallback.
|
|
32
|
+
const aclEntries = entries.filter((entry) => entry.trim().length > 0);
|
|
33
|
+
const unsafe = process.platform === 'darwin'
|
|
34
|
+
? (aclEntries.length > 0
|
|
35
|
+
? aclEntries.some((entry) => !/^\s*\d+:\s+.+\sdeny\s+/.test(entry))
|
|
36
|
+
: permissions[10] === '+')
|
|
37
|
+
: permissions[10] === '+';
|
|
38
|
+
if (aclCache.size >= 1024) aclCache.clear();
|
|
39
|
+
aclCache.set(directory, { signature, unsafe });
|
|
40
|
+
return unsafe;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stableProjectStateRoot(root) {
|
|
44
|
+
if (typeof process.getuid !== 'function') return null;
|
|
45
|
+
const uid = process.getuid();
|
|
46
|
+
let canonical;
|
|
47
|
+
try { canonical = fs.realpathSync.native(path.resolve(root)); }
|
|
48
|
+
catch { return null; }
|
|
49
|
+
const parts = [];
|
|
50
|
+
for (let current = canonical;; current = path.dirname(current)) {
|
|
51
|
+
parts.push(current);
|
|
52
|
+
if (path.dirname(current) === current) break;
|
|
53
|
+
}
|
|
54
|
+
parts.reverse();
|
|
55
|
+
let parent = null;
|
|
56
|
+
for (const component of parts) {
|
|
57
|
+
let stat;
|
|
58
|
+
try { stat = fs.lstatSync(component); }
|
|
59
|
+
catch { return null; }
|
|
60
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
61
|
+
(stat.uid !== uid && stat.uid !== 0) || unsafeDirectoryAcl(component, stat)) return null;
|
|
62
|
+
if (parent) {
|
|
63
|
+
const parentMode = parent.mode & 0o7777;
|
|
64
|
+
if ((parentMode & 0o022) !== 0 &&
|
|
65
|
+
((parentMode & 0o1000) === 0 || (stat.uid !== uid && stat.uid !== 0))) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
parent = stat;
|
|
70
|
+
}
|
|
71
|
+
// A cross-UID-writable root can have its .hello-cc entry replaced. Keep its
|
|
72
|
+
// state under the user's private state directory even when root is sticky.
|
|
73
|
+
if ((parent.mode & 0o022) !== 0) return null;
|
|
74
|
+
// A stable but read-only server directory (for example /etc) cannot host a
|
|
75
|
+
// new project-local state directory. It remains selectable via private state.
|
|
76
|
+
try { fs.accessSync(canonical, fs.constants.W_OK | fs.constants.X_OK); }
|
|
77
|
+
catch { return null; }
|
|
78
|
+
return canonical;
|
|
79
|
+
}
|
package/lib/runtime/projects.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { projectDbPath, projectRegistryPath } from './paths.mjs';
|
|
3
|
+
import { projectDbPath, projectRegistryPath, projectStateDir } from './paths.mjs';
|
|
4
4
|
import { withFileLock } from '../shared/file-lock.mjs';
|
|
5
5
|
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
6
|
+
import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
6
7
|
|
|
7
8
|
const ROOT_EXISTS = Symbol('projectRootExists');
|
|
8
9
|
const ROOT_WAS_CANONICAL = Symbol('projectRootWasCanonical');
|
|
@@ -101,9 +102,12 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
|
|
|
101
102
|
const db = pathWithin(rawRoot, rawDb)
|
|
102
103
|
? path.resolve(rootInfo.root, relativeToRawRoot)
|
|
103
104
|
: rawDb;
|
|
105
|
+
const privateState = projectStateDir(rootInfo.root);
|
|
106
|
+
const allowed = (candidate) => pathWithin(rootInfo.root, candidate) ||
|
|
107
|
+
(privateState !== path.join(rootInfo.root, '.hello-cc') && pathWithin(privateState, candidate));
|
|
104
108
|
|
|
105
109
|
if (!rootInfo.exists) {
|
|
106
|
-
return
|
|
110
|
+
return allowed(db)
|
|
107
111
|
? db
|
|
108
112
|
: reject(`project DB is outside the project root: ${rawDb}`);
|
|
109
113
|
}
|
|
@@ -145,7 +149,7 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
|
|
|
145
149
|
const canonicalDb = dbExists
|
|
146
150
|
? canonicalAncestor
|
|
147
151
|
: path.resolve(canonicalAncestor, path.relative(ancestor, db));
|
|
148
|
-
if (!
|
|
152
|
+
if (!allowed(canonicalDb)) {
|
|
149
153
|
return reject(`project DB is outside the project root: ${rawDb}`);
|
|
150
154
|
}
|
|
151
155
|
return canonicalDb;
|
|
@@ -302,6 +306,7 @@ function updateProjectRegistry(candidate, lockOptions = {}) {
|
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
export function registerProject(ctx, lockOptions = {}) {
|
|
309
|
+
if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
305
310
|
const candidate = projectRecord(ctx);
|
|
306
311
|
if (!candidate) throw new TypeError('project root is required');
|
|
307
312
|
// xx-01: the throttle check lives inside the lock (canonical-domain safe, see
|
|
@@ -311,6 +316,7 @@ export function registerProject(ctx, lockOptions = {}) {
|
|
|
311
316
|
}
|
|
312
317
|
|
|
313
318
|
export function registerProjectActivity(ctx) {
|
|
319
|
+
if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
314
320
|
try {
|
|
315
321
|
const wallTimestamp = Date.now();
|
|
316
322
|
const candidate = projectRecord(ctx, () => Math.floor(wallTimestamp / 1000));
|