@logicseek/hello-cc 1.0.2 → 1.1.0-rc.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +111 -23
  2. package/README.md +10 -1
  3. package/README.zh-CN.md +8 -1
  4. package/docs/commands.md +28 -4
  5. package/docs/commands.zh-CN.md +20 -4
  6. package/docs/dsh.md +2 -2
  7. package/docs/dsh.zh-CN.md +3 -3
  8. package/docs/native.md +84 -10
  9. package/docs/native.zh-CN.md +66 -5
  10. package/docs/web-handoff.zh-CN.md +11 -3
  11. package/lib/cli/app.mjs +42 -1
  12. package/lib/cli/commands/coordination.mjs +7 -2
  13. package/lib/cli/commands/dsh.mjs +1 -1
  14. package/lib/cli/commands/gc.mjs +9 -4
  15. package/lib/cli/commands/hook.mjs +6 -1
  16. package/lib/cli/commands/install.mjs +48 -6
  17. package/lib/cli/commands/lifecycle.mjs +14 -3
  18. package/lib/cli/commands/native.mjs +16 -9
  19. package/lib/cli/commands/query.mjs +15 -10
  20. package/lib/cli/commands/run.mjs +7 -6
  21. package/lib/core/coordination/results.mjs +27 -10
  22. package/lib/db/connection.mjs +135 -10
  23. package/lib/db/schema.mjs +59 -0
  24. package/lib/discover.mjs +4 -4
  25. package/lib/guidance.mjs +3 -2
  26. package/lib/integrations/codex-interactions.mjs +13 -11
  27. package/lib/integrations/dsh-collaboration.mjs +6 -12
  28. package/lib/integrations/dsh-cordis.mjs +17 -13
  29. package/lib/integrations/dsh.mjs +12 -9
  30. package/lib/integrations/hooks.mjs +132 -40
  31. package/lib/integrations/mcp-elicitation.mjs +116 -0
  32. package/lib/integrations/mcp-url-elicitation.mjs +25 -0
  33. package/lib/integrations/native/interactions.mjs +2 -1
  34. package/lib/integrations/peers/identity.mjs +42 -7
  35. package/lib/process/identity.mjs +15 -14
  36. package/lib/runtime/buffer-evidence.mjs +2 -1
  37. package/lib/runtime/native/service.mjs +11 -5
  38. package/lib/runtime/native/store.mjs +70 -18
  39. package/lib/runtime/paths.mjs +17 -2
  40. package/lib/runtime/private-file.mjs +76 -0
  41. package/lib/runtime/private-state.mjs +546 -0
  42. package/lib/runtime/project-context.mjs +2 -1
  43. package/lib/runtime/project-path.mjs +148 -12
  44. package/lib/runtime/project-trust.mjs +79 -0
  45. package/lib/runtime/projects.mjs +6 -3
  46. package/lib/runtime/state-migration.mjs +697 -0
  47. package/lib/runtime/state.mjs +22 -11
  48. package/lib/shared/automatic-result-key.mjs +6 -0
  49. package/lib/shared/json-file.mjs +55 -6
  50. package/lib/ui/help.mjs +6 -4
  51. package/lib/web/codex-app-server.mjs +26 -5
  52. package/lib/web/codex-sessions.mjs +5 -3
  53. package/lib/web/cookie-auth.mjs +1 -1
  54. package/lib/web/external-sessions.mjs +54 -5
  55. package/lib/web/http-routes.mjs +13 -9
  56. package/lib/web/http.mjs +20 -2
  57. package/lib/web/native-sessions.mjs +14 -7
  58. package/lib/web/peer-actions.mjs +27 -27
  59. package/lib/web/project-contexts.mjs +2 -3
  60. package/lib/web/runtime-main.mjs +22 -5
  61. package/lib/web/runtime.mjs +18 -4
  62. package/lib/web/session-serialize.mjs +1 -1
  63. package/lib/web/startup.mjs +115 -22
  64. package/lib/web/task-results.mjs +20 -17
  65. package/lib/web/terminal-assets.mjs +18 -0
  66. package/lib/web/tls.mjs +84 -33
  67. package/lib/web/tmux-sessions.mjs +2 -2
  68. package/lib/web/tmux-stream.mjs +3 -4
  69. package/lib/web/ui-codex.mjs +161 -32
  70. package/lib/web/ui-interactions.mjs +137 -4
  71. package/lib/web/ui-native.mjs +31 -13
  72. package/lib/web/ui-template.mjs +10 -3
  73. package/package.json +1 -1
  74. package/scripts/mcp-form-live-fixture.mjs +81 -0
  75. package/scripts/native-installed-acceptance.mjs +413 -0
  76. package/scripts/regression.mjs +46 -29
  77. package/scripts/web-mcp-form-acceptance.mjs +119 -0
  78. package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
  79. package/scripts/web-native-interaction-acceptance.mjs +77 -10
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
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';
4
8
 
5
9
  function pathIsWithin(parent, candidate) {
6
10
  const relative = path.relative(parent, candidate);
@@ -22,8 +26,85 @@ function canonicalProjectRoot(root) {
22
26
  }
23
27
  }
24
28
 
25
- function ensureStateDirectory(root, createStateDir) {
26
- const stateDir = path.join(root, '.hello-cc');
29
+ function privateDirectory(directory, description, { tighten = true } = {}) {
30
+ let stat;
31
+ try { stat = fs.lstatSync(directory); }
32
+ catch { throw projectPathForbidden(`Cannot inspect ${description}: ${directory}`); }
33
+ if (stat.isSymbolicLink() || !stat.isDirectory() ||
34
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid())) {
35
+ throw projectPathForbidden(`${description} must be an owned real directory: ${directory}`);
36
+ }
37
+
38
+ let fd;
39
+ try {
40
+ fd = fs.openSync(directory, fs.constants.O_RDONLY |
41
+ (fs.constants.O_DIRECTORY || 0) | (fs.constants.O_NOFOLLOW || 0));
42
+ const opened = fs.fstatSync(fd);
43
+ if (!opened.isDirectory() || opened.dev !== stat.dev || opened.ino !== stat.ino ||
44
+ unsafeDirectoryAcl(directory, opened)) {
45
+ throw projectPathForbidden(`${description} changed during validation: ${directory}`);
46
+ }
47
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) {
48
+ if (tighten) fs.fchmodSync(fd, 0o700);
49
+ else if ((opened.mode & 0o022) !== 0) {
50
+ throw projectPathForbidden(`${description} is writable by another user: ${directory}`);
51
+ }
52
+ }
53
+ const current = fs.lstatSync(directory);
54
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
55
+ throw projectPathForbidden(`${description} changed during validation: ${directory}`);
56
+ }
57
+ return fs.realpathSync(directory);
58
+ } catch (error) {
59
+ if (error instanceof CliError) throw error;
60
+ throw projectPathForbidden(`Cannot secure ${description}: ${directory}`);
61
+ } finally {
62
+ if (fd !== undefined) fs.closeSync(fd);
63
+ }
64
+ }
65
+
66
+ function ensureStateDirectory(root, createStateDir, secureExisting = createStateDir) {
67
+ const stateDir = projectStateDir(root);
68
+ if (stateDir !== legacyProjectStateDir(root)) {
69
+ const authority = inspectPrivateProjectAuthority(root);
70
+ let privateEntryExists = false;
71
+ try { fs.lstatSync(stateDir); privateEntryExists = true; }
72
+ catch (error) {
73
+ if (error?.code !== 'ENOENT') {
74
+ throw projectPathForbidden(`Cannot inspect private project state: ${stateDir}`);
75
+ }
76
+ }
77
+ // A crash can leave a pending marker with an empty directory or a valid
78
+ // manifest not yet promoted to fresh. Older private stores may have no
79
+ // sibling marker. Finish either binding before returning any existing
80
+ // state, including during a read-only request.
81
+ const finishBinding = privateEntryExists &&
82
+ (authority === null || authority === 'pending');
83
+ const existing = ensurePrivateProjectStateDir(root, { create: finishBinding });
84
+ if (existing) return { stateDir, exists: true };
85
+ if (authority === 'fresh' || authority === 'migrated') {
86
+ throw new CliError('STATE_AUTHORITY_MISSING',
87
+ `Previously established private project state is missing: ${stateDir}. Restore it from backup; ` +
88
+ 'if the legacy source still exists, stop all old writers and run ' +
89
+ 'hcc --root DIR migrate-state --offline --yes; otherwise run ' +
90
+ 'hcc --root DIR uninstall --purge --yes to explicitly reset the missing state.');
91
+ }
92
+ const legacy = legacyProjectStateDir(root);
93
+ let legacyStat = null;
94
+ try { legacyStat = fs.lstatSync(legacy); }
95
+ catch (error) {
96
+ if (error?.code !== 'ENOENT') throw projectPathForbidden(`Cannot inspect legacy project state: ${legacy}`);
97
+ }
98
+ if (legacyStat && (legacyStat.isSymbolicLink() || !legacyStat.isDirectory())) {
99
+ throw projectPathForbidden(`Legacy project state is not a real directory: ${legacy}`);
100
+ }
101
+ if (legacyStat) {
102
+ throw new CliError('STATE_MIGRATION_REQUIRED',
103
+ `Legacy project state requires an offline migration before use: ${legacy}`);
104
+ }
105
+ const secured = createStateDir ? ensurePrivateProjectStateDir(root, { create: true }) : null;
106
+ return { stateDir, exists: Boolean(secured) };
107
+ }
27
108
  let stateStat;
28
109
  try {
29
110
  stateStat = fs.lstatSync(stateDir);
@@ -35,8 +116,9 @@ function ensureStateDirectory(root, createStateDir) {
35
116
  try {
36
117
  fs.mkdirSync(stateDir, { mode: 0o700 });
37
118
  stateStat = fs.lstatSync(stateDir);
38
- } catch {
39
- throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
119
+ } catch (error) {
120
+ if (error?.code === 'EEXIST') stateStat = fs.lstatSync(stateDir);
121
+ else throw projectPathForbidden(`Cannot create project state directory: ${stateDir}`);
40
122
  }
41
123
  }
42
124
 
@@ -44,7 +126,7 @@ function ensureStateDirectory(root, createStateDir) {
44
126
  throw projectPathForbidden(`Project state directory must be a real directory: ${stateDir}`);
45
127
  }
46
128
  try {
47
- const canonical = fs.realpathSync(stateDir);
129
+ const canonical = privateDirectory(stateDir, 'Project state directory', { tighten: secureExisting });
48
130
  if (canonical !== stateDir || !pathIsWithin(root, canonical)) {
49
131
  throw projectPathForbidden(`Project state directory escapes its project root: ${stateDir}`);
50
132
  }
@@ -55,13 +137,26 @@ function ensureStateDirectory(root, createStateDir) {
55
137
  }
56
138
  }
57
139
 
58
- function validateDatabaseParent(stateDir, db) {
140
+ export function secureExistingProjectStateDirectory(root) {
141
+ const canonicalRoot = canonicalProjectRoot(root);
142
+ return ensureStateDirectory(canonicalRoot, false, true);
143
+ }
144
+
145
+ function validateDatabaseParent(stateDir, db, createParents = false) {
59
146
  const parent = path.dirname(db);
60
147
  const relative = path.relative(stateDir, parent);
61
148
  const segments = relative === '' ? [] : relative.split(path.sep);
62
149
  let current = stateDir;
63
150
  for (const segment of segments) {
64
151
  current = path.join(current, segment);
152
+ if (createParents) {
153
+ try { fs.mkdirSync(current, { mode: 0o700 }); }
154
+ catch (error) {
155
+ if (error?.code !== 'EEXIST') {
156
+ throw projectPathForbidden(`Cannot create database parent directory: ${current}`);
157
+ }
158
+ }
159
+ }
65
160
  let stat;
66
161
  try {
67
162
  stat = fs.lstatSync(current);
@@ -71,6 +166,9 @@ function validateDatabaseParent(stateDir, db) {
71
166
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
72
167
  throw projectPathForbidden(`Database parent must contain only real directories: ${current}`);
73
168
  }
169
+ if (privateDirectory(current, 'Database parent directory', { tighten: createParents }) !== current) {
170
+ throw projectPathForbidden(`Database parent directory changed: ${current}`);
171
+ }
74
172
  }
75
173
 
76
174
  try {
@@ -107,14 +205,18 @@ function validateDatabaseTarget(stateDir, db) {
107
205
  }
108
206
  }
109
207
 
110
- export function resolveProjectDatabase({ root, db, createStateDir = false }) {
208
+ export function resolveProjectDatabase({ root, db, createStateDir = false, createDatabaseParents = false }) {
111
209
  const requestedRoot = path.resolve(String(root || ''));
112
210
  const canonicalRoot = canonicalProjectRoot(requestedRoot);
113
211
  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');
212
+ const intendedStateDir = projectStateDir(canonicalRoot);
213
+ const requestedLegacyState = legacyProjectStateDir(requestedRoot);
214
+ const canonicalLegacyState = legacyProjectStateDir(canonicalRoot);
215
+ let requestedDb = pathIsWithin(requestedLegacyState, rawDb)
216
+ ? path.join(intendedStateDir, path.relative(requestedLegacyState, rawDb))
217
+ : pathIsWithin(canonicalLegacyState, rawDb)
218
+ ? path.join(intendedStateDir, path.relative(canonicalLegacyState, rawDb))
219
+ : rawDb;
118
220
  if (!pathIsWithin(intendedStateDir, requestedDb)) {
119
221
  try {
120
222
  const canonicalParent = fs.realpathSync(path.dirname(requestedDb));
@@ -130,10 +232,44 @@ export function resolveProjectDatabase({ root, db, createStateDir = false }) {
130
232
  return { root: canonicalRoot, stateDir: state.stateDir, db: requestedDb };
131
233
  }
132
234
 
133
- validateDatabaseParent(state.stateDir, requestedDb);
235
+ validateDatabaseParent(state.stateDir, requestedDb, Boolean(createDatabaseParents));
134
236
  return {
135
237
  root: canonicalRoot,
136
238
  stateDir: state.stateDir,
137
239
  db: validateDatabaseTarget(state.stateDir, requestedDb)
138
240
  };
139
241
  }
242
+
243
+ export function ensurePrivateProjectStateSubdirectory(root, directoryName, { create = true } = {}) {
244
+ if (!directoryName || directoryName !== path.basename(directoryName) ||
245
+ directoryName === '.' || directoryName === '..') {
246
+ throw projectPathForbidden(`Invalid buffer directory name: ${directoryName}`);
247
+ }
248
+ const canonicalRoot = canonicalProjectRoot(root);
249
+ const state = ensureStateDirectory(canonicalRoot, create, create);
250
+ if (!state.exists) return null;
251
+ const { stateDir } = state;
252
+ const directory = path.join(stateDir, directoryName);
253
+ if (create) {
254
+ try { fs.mkdirSync(directory, { mode: 0o700 }); }
255
+ catch (error) {
256
+ if (error?.code !== 'EEXIST') {
257
+ throw projectPathForbidden(`Cannot create buffer directory: ${directory}`);
258
+ }
259
+ }
260
+ } else {
261
+ try { fs.lstatSync(directory); }
262
+ catch (error) {
263
+ if (error?.code === 'ENOENT') return null;
264
+ throw projectPathForbidden(`Cannot inspect buffer directory: ${directory}`);
265
+ }
266
+ }
267
+ if (privateDirectory(directory, 'Project state subdirectory', { tighten: create }) !== directory) {
268
+ throw projectPathForbidden(`Buffer directory escapes project state directory: ${directory}`);
269
+ }
270
+ return directory;
271
+ }
272
+
273
+ export function ensurePrivateProjectBufferDirectory(root, directoryName = 'bufs', options = {}) {
274
+ return ensurePrivateProjectStateSubdirectory(root, directoryName, options);
275
+ }
@@ -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
+ }
@@ -1,6 +1,6 @@
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
6
 
@@ -101,9 +101,12 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
101
101
  const db = pathWithin(rawRoot, rawDb)
102
102
  ? path.resolve(rootInfo.root, relativeToRawRoot)
103
103
  : rawDb;
104
+ const privateState = projectStateDir(rootInfo.root);
105
+ const allowed = (candidate) => pathWithin(rootInfo.root, candidate) ||
106
+ (privateState !== path.join(rootInfo.root, '.hello-cc') && pathWithin(privateState, candidate));
104
107
 
105
108
  if (!rootInfo.exists) {
106
- return pathWithin(rootInfo.root, db)
109
+ return allowed(db)
107
110
  ? db
108
111
  : reject(`project DB is outside the project root: ${rawDb}`);
109
112
  }
@@ -145,7 +148,7 @@ function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
145
148
  const canonicalDb = dbExists
146
149
  ? canonicalAncestor
147
150
  : path.resolve(canonicalAncestor, path.relative(ancestor, db));
148
- if (!pathWithin(rootInfo.root, canonicalDb)) {
151
+ if (!allowed(canonicalDb)) {
149
152
  return reject(`project DB is outside the project root: ${rawDb}`);
150
153
  }
151
154
  return canonicalDb;