@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,32 +1,70 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import process from 'node:process';
3
4
  import { DatabaseSync } from 'node:sqlite';
4
5
  import { randomBytes } from 'node:crypto';
5
6
  import { CliError } from '../../shared/errors.mjs';
7
+ import { resolveProjectDatabase, ensurePrivateProjectStateSubdirectory } from '../project-path.mjs';
8
+ import { unsafeDirectoryAcl } from '../project-trust.mjs';
9
+ import { readPrivateTextFile } from '../private-file.mjs';
6
10
 
7
11
  function statOrMissing(file) {
8
12
  try { return fs.lstatSync(file); }
9
13
  catch (error) { if (error.code === 'ENOENT') return null; throw error; }
10
14
  }
11
15
 
12
- function directory(file, create) {
16
+ function privateDatabase(file) {
13
17
  let stat = statOrMissing(file);
14
- if (!stat && create) { fs.mkdirSync(file, { mode: 0o700 }); stat = fs.lstatSync(file); }
15
- if (!stat) return false;
16
- if (!stat.isDirectory() || stat.isSymbolicLink()) throw new CliError('NATIVE_STATE_UNSAFE', `Native state must be a real directory: ${file}`);
17
- return true;
18
+ if (!stat) {
19
+ try {
20
+ const fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_CREAT |
21
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
22
+ fs.closeSync(fd);
23
+ } catch (error) { if (error?.code !== 'EEXIST') throw error; }
24
+ stat = fs.lstatSync(file);
25
+ }
26
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
27
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
28
+ (process.platform !== 'win32' && (stat.mode & 0o022) !== 0) ||
29
+ unsafeDirectoryAcl(file, stat)) {
30
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database must be an owned regular file: ${file}`);
31
+ }
32
+ const fd = fs.openSync(file, fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW || 0));
33
+ try {
34
+ const opened = fs.fstatSync(fd);
35
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino ||
36
+ (process.platform !== 'win32' && (opened.mode & 0o022) !== 0) ||
37
+ unsafeDirectoryAcl(file, opened)) {
38
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
39
+ }
40
+ if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) fs.fchmodSync(fd, 0o600);
41
+ const current = fs.lstatSync(file);
42
+ if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
43
+ throw new CliError('NATIVE_STATE_UNSAFE', `Native database changed during validation: ${file}`);
44
+ }
45
+ } finally { fs.closeSync(fd); }
18
46
  }
19
47
 
20
48
  export function nativePaths(ctx, { create = false } = {}) {
21
49
  const root = fs.realpathSync(ctx.root);
22
- const parent = path.join(root, '.hello-cc');
23
- if (!directory(parent, create)) return null;
24
- const dir = path.join(parent, 'native');
25
- if (!directory(dir, create)) return null;
50
+ let dir;
51
+ try {
52
+ resolveProjectDatabase({ root, createStateDir: create });
53
+ dir = ensurePrivateProjectStateSubdirectory(root, 'native', { create });
54
+ } catch (error) {
55
+ if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
56
+ throw new CliError('NATIVE_STATE_UNSAFE', error.message);
57
+ }
58
+ throw error;
59
+ }
60
+ if (!dir) return null;
26
61
  for (const name of ['runtime.json', 'state.db', 'state.db-wal', 'state.db-shm', 'runtime.log']) {
27
62
  const file = path.join(dir, name);
28
63
  const stat = statOrMissing(file);
29
- if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1)) {
64
+ if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 ||
65
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
66
+ (process.platform !== 'win32' && (stat.mode & 0o022) !== 0) ||
67
+ unsafeDirectoryAcl(file, stat))) {
30
68
  throw new CliError('NATIVE_STATE_UNSAFE', `Native state file is not a regular file: ${file}`);
31
69
  }
32
70
  }
@@ -43,10 +81,18 @@ export function writeNativePointer(ctx, value) {
43
81
 
44
82
  export function readNativePointer(ctx) {
45
83
  const paths = nativePaths(ctx);
46
- if (!paths || !fs.existsSync(paths.pointer)) return null;
47
- if (fs.statSync(paths.pointer).size > 16384) throw new CliError('NATIVE_STATE_UNSAFE', 'Native runtime pointer is too large');
84
+ if (!paths) return null;
85
+ let raw;
86
+ try { raw = readPrivateTextFile(paths.pointer, { maxBytes: 16384 }); }
87
+ catch (error) {
88
+ if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
89
+ throw new CliError('NATIVE_STATE_UNSAFE', error.message);
90
+ }
91
+ throw error;
92
+ }
93
+ if (raw === null) return null;
48
94
  let value;
49
- try { value = JSON.parse(fs.readFileSync(paths.pointer, 'utf8')); }
95
+ try { value = JSON.parse(raw); }
50
96
  catch { throw new CliError('NATIVE_STATE_INVALID', 'Native runtime pointer is invalid'); }
51
97
  if (!value || typeof value !== 'object' || Array.isArray(value) ||
52
98
  value.root !== fs.realpathSync(ctx.root) || value.meshDb !== path.resolve(ctx.dbPath) ||
@@ -60,8 +106,8 @@ export function readNativePointer(ctx) {
60
106
 
61
107
  export function createNativeStore(ctx) {
62
108
  const paths = nativePaths(ctx, { create: true });
109
+ privateDatabase(paths.db);
63
110
  const db = new DatabaseSync(paths.db, { timeout: 5000 });
64
- fs.chmodSync(paths.db, 0o600);
65
111
  db.exec(`PRAGMA journal_mode = WAL;
66
112
  PRAGMA busy_timeout = 5000;
67
113
  PRAGMA foreign_keys = ON;
@@ -72,11 +118,16 @@ export function createNativeStore(ctx) {
72
118
  CREATE TABLE IF NOT EXISTS deliveries (
73
119
  id INTEGER PRIMARY KEY, peer TEXT NOT NULL, message_id INTEGER NOT NULL,
74
120
  submission_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, turn_id TEXT,
75
- detail TEXT, updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
121
+ detail TEXT, origin TEXT NOT NULL DEFAULT 'peer', updated_at INTEGER NOT NULL, UNIQUE(peer, message_id)
76
122
  );
77
123
  CREATE TABLE IF NOT EXISTS provider_events (
78
124
  id INTEGER PRIMARY KEY, peer TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL
79
125
  );`);
126
+ // Existing deliveries came from the shared message bus. Migration must not
127
+ // promote a stored sender name into user authority.
128
+ if (!db.prepare('PRAGMA table_info(deliveries)').all().some(column => column.name === 'origin')) {
129
+ db.exec("ALTER TABLE deliveries ADD COLUMN origin TEXT NOT NULL DEFAULT 'peer'");
130
+ }
80
131
  const now = () => Date.now();
81
132
  return {
82
133
  db,
@@ -88,9 +139,10 @@ export function createNativeStore(ctx) {
88
139
  cwd=excluded.cwd, status=excluded.status, updated_at=excluded.updated_at`)
89
140
  .run(worker.peer, worker.provider, worker.sessionId || null, worker.cwd, worker.status, now());
90
141
  },
91
- queue(peer, messageId, submissionId) {
92
- db.prepare(`INSERT OR IGNORE INTO deliveries(peer,message_id,submission_id,state,updated_at) VALUES (?,?,?,'queued',?)`)
93
- .run(peer, messageId, submissionId, now());
142
+ queue(peer, messageId, submissionId, origin = 'peer') {
143
+ if (!['peer', 'user'].includes(origin)) throw new CliError('BAD_ARGS', 'Unknown native message origin');
144
+ db.prepare(`INSERT OR IGNORE INTO deliveries(peer,message_id,submission_id,state,origin,updated_at) VALUES (?,?,?,'queued',?,?)`)
145
+ .run(peer, messageId, submissionId, origin, now());
94
146
  return this.delivery(peer, messageId);
95
147
  },
96
148
  delivery(peer, messageId) { return db.prepare('SELECT * FROM deliveries WHERE peer=? AND message_id=?').get(peer, messageId) || null; },
@@ -1,7 +1,22 @@
1
- import os from 'node:os';
1
+ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { privateProjectStateDir, privateProjectAuthorityPath, validatedGlobalStateDir } from './private-state.mjs';
4
+ import { stableProjectStateRoot } from './project-trust.mjs';
3
5
 
4
6
  export function projectStateDir(root) {
7
+ const privateDir = privateProjectStateDir(root);
8
+ // An established private binding is authoritative even if permissions on
9
+ // the project path later change. A damaged or unreadable private store must
10
+ // fail validation, never silently reactivate an old project-local database.
11
+ try { fs.lstatSync(privateDir); return privateDir; }
12
+ catch (error) { if (error?.code !== 'ENOENT') return privateDir; }
13
+ try { fs.lstatSync(privateProjectAuthorityPath(root)); return privateDir; }
14
+ catch (error) { if (error?.code !== 'ENOENT') return privateDir; }
15
+ const stableRoot = stableProjectStateRoot(root);
16
+ return stableRoot ? path.join(stableRoot, '.hello-cc') : privateDir;
17
+ }
18
+
19
+ export function legacyProjectStateDir(root) {
5
20
  return path.join(root, '.hello-cc');
6
21
  }
7
22
 
@@ -18,7 +33,7 @@ export function webLogPath(ctx) {
18
33
  }
19
34
 
20
35
  export function globalStateDir() {
21
- return path.join(os.homedir(), '.hello-cc');
36
+ return validatedGlobalStateDir();
22
37
  }
23
38
 
24
39
  export function globalRuntimePath() {
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs';
2
+ import process from 'node:process';
3
+ import { CliError } from '../shared/errors.mjs';
4
+ import { unsafeDirectoryAcl } from './project-trust.mjs';
5
+
6
+ function forbidden(file) {
7
+ return new CliError('PROJECT_PATH_FORBIDDEN', `Managed file must be an owned private regular file: ${file}`);
8
+ }
9
+
10
+ export function openPrivateAppendFile(file) {
11
+ const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT |
12
+ (fs.constants.O_NOFOLLOW || 0);
13
+ let fd;
14
+ try {
15
+ fd = fs.openSync(file, flags, 0o600);
16
+ const opened = fs.fstatSync(fd);
17
+ if (!opened.isFile() || opened.nlink !== 1 ||
18
+ (typeof process.getuid === 'function' && opened.uid !== process.getuid()) ||
19
+ (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) ||
20
+ unsafeDirectoryAcl(file, opened)) {
21
+ throw forbidden(file);
22
+ }
23
+ const current = fs.lstatSync(file);
24
+ const secured = fs.fstatSync(fd);
25
+ if (current.isSymbolicLink() || !current.isFile() || current.nlink !== 1 ||
26
+ current.dev !== secured.dev || current.ino !== secured.ino ||
27
+ (typeof process.getuid === 'function' && current.uid !== process.getuid()) ||
28
+ (process.platform !== 'win32' && (secured.mode & 0o777) !== 0o600)) {
29
+ throw forbidden(file);
30
+ }
31
+ return fd;
32
+ } catch (error) {
33
+ if (fd !== undefined) fs.closeSync(fd);
34
+ if (error instanceof CliError) throw error;
35
+ throw forbidden(file);
36
+ }
37
+ }
38
+
39
+ export function readPrivateTextFile(file, { maxBytes = 65536 } = {}) {
40
+ let before;
41
+ try { before = fs.lstatSync(file); }
42
+ catch (error) {
43
+ if (error?.code === 'ENOENT') return null;
44
+ throw forbidden(file);
45
+ }
46
+ if (before.isSymbolicLink() || !before.isFile() || before.nlink !== 1 ||
47
+ (typeof process.getuid === 'function' && before.uid !== process.getuid()) ||
48
+ (process.platform !== 'win32' && (before.mode & 0o777) !== 0o600) ||
49
+ before.size > maxBytes || unsafeDirectoryAcl(file, before)) {
50
+ throw forbidden(file);
51
+ }
52
+ let fd;
53
+ try {
54
+ fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
55
+ const opened = fs.fstatSync(fd);
56
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev ||
57
+ opened.ino !== before.ino || opened.size > maxBytes ||
58
+ (typeof process.getuid === 'function' && opened.uid !== process.getuid()) ||
59
+ (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600)) {
60
+ throw forbidden(file);
61
+ }
62
+ const content = fs.readFileSync(fd, 'utf8');
63
+ const after = fs.lstatSync(file);
64
+ const current = fs.fstatSync(fd);
65
+ if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
66
+ current.size !== opened.size || current.mtimeMs !== opened.mtimeMs) {
67
+ throw forbidden(file);
68
+ }
69
+ return content;
70
+ } catch (error) {
71
+ if (error instanceof CliError) throw error;
72
+ throw forbidden(file);
73
+ } finally {
74
+ if (fd !== undefined) fs.closeSync(fd);
75
+ }
76
+ }