@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
@@ -5,6 +5,7 @@
5
5
 
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import process from 'node:process';
8
9
  import { DatabaseSync } from 'node:sqlite';
9
10
  import { CliError } from '../shared/errors.mjs';
10
11
  import {
@@ -13,26 +14,143 @@ import {
13
14
  } from './schema.mjs';
14
15
  import { initSchemaWithBackup } from './migration-backup.mjs';
15
16
  import { readProjectRegistry } from '../runtime/projects.mjs';
16
- import { resolveProjectDatabase } from '../runtime/project-path.mjs';
17
+ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from '../runtime/project-path.mjs';
18
+ import { projectStateDir, legacyProjectStateDir } from '../runtime/paths.mjs';
19
+ import { privateProjectStateDir, ensurePrivateProjectStateDir } from '../runtime/private-state.mjs';
20
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
17
21
 
18
22
  const MIGRATION_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
19
23
 
24
+ function managedDatabase(ctx) {
25
+ if (!ctx.root) return false;
26
+ const requestedRoot = path.resolve(ctx.root);
27
+ let canonicalRoot = requestedRoot;
28
+ try { canonicalRoot = fs.realpathSync.native(requestedRoot); } catch {}
29
+ const requestedDb = path.resolve(ctx.dbPath);
30
+ let canonicalCandidate = requestedDb;
31
+ try {
32
+ canonicalCandidate = path.join(fs.realpathSync.native(path.dirname(requestedDb)), path.basename(requestedDb));
33
+ } catch {}
34
+ return [requestedRoot, canonicalRoot].some(root =>
35
+ [legacyProjectStateDir(root), privateProjectStateDir(root)].some(stateDir =>
36
+ [requestedDb, canonicalCandidate].some(db => {
37
+ const relative = path.relative(stateDir, db);
38
+ return relative !== '' && relative !== '..' &&
39
+ !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
40
+ })));
41
+ }
42
+
43
+ function secureOwnedSqliteFile(file, { create = false, readOnly = false, optional = false } = {}) {
44
+ const changed = () => new CliError('PROJECT_PATH_FORBIDDEN', `SQLite file changed during validation: ${file}`);
45
+ const safeStat = stat => stat && !stat.isSymbolicLink() && stat.isFile() && stat.nlink === 1 &&
46
+ (typeof process.getuid !== 'function' || stat.uid === process.getuid()) &&
47
+ (process.platform === 'win32' || (stat.mode & 0o022) === 0) &&
48
+ !unsafeDirectoryAcl(file, stat);
49
+ const stillSame = (current, original) => safeStat(current) &&
50
+ current.dev === original.dev && current.ino === original.ino;
51
+ const recheck = () => {
52
+ try { return fs.lstatSync(file); }
53
+ catch (error) {
54
+ if (!optional || error?.code !== 'ENOENT') throw error;
55
+ // An optional WAL/SHM/journal may disappear as SQLite closes its last
56
+ // connection. Do not accept a different file rebuilt at the same path.
57
+ try { fs.lstatSync(file); }
58
+ catch (again) {
59
+ if (again?.code === 'ENOENT') return null;
60
+ throw again;
61
+ }
62
+ throw changed();
63
+ }
64
+ };
65
+ let stat;
66
+ try { stat = fs.lstatSync(file); }
67
+ catch (error) {
68
+ if (error?.code !== 'ENOENT') throw error;
69
+ if (!create) return false;
70
+ try {
71
+ const fd = fs.openSync(file, fs.constants.O_RDWR | fs.constants.O_CREAT |
72
+ fs.constants.O_EXCL | (fs.constants.O_NOFOLLOW || 0), 0o600);
73
+ fs.closeSync(fd);
74
+ } catch (creationError) {
75
+ if (creationError?.code !== 'EEXIST') throw creationError;
76
+ }
77
+ stat = fs.lstatSync(file);
78
+ }
79
+ if (!safeStat(stat)) {
80
+ // ACL inspection invokes ls and may outlive a retiring optional sidecar.
81
+ // Missing is safe to skip; a rebuilt or otherwise unsafe file is not.
82
+ if (optional && !recheck()) return false;
83
+ throw new CliError('PROJECT_PATH_FORBIDDEN', `SQLite file must be an owned non-writable regular file: ${file}`);
84
+ }
85
+ // Never open/close an existing SQLite file outside SQLite: on POSIX, close()
86
+ // can release locks held by another SQLite connection in this process.
87
+ const current = recheck();
88
+ if (!current) return false;
89
+ if (!stillSame(current, stat)) throw changed();
90
+ if (!readOnly && process.platform !== 'win32' && (current.mode & 0o777) !== 0o600) {
91
+ try { fs.chmodSync(file, 0o600); }
92
+ catch (error) {
93
+ if (!optional || error?.code !== 'ENOENT') throw error;
94
+ if (!recheck()) return false;
95
+ throw changed();
96
+ }
97
+ const tightened = recheck();
98
+ if (!tightened) return false;
99
+ if (!stillSame(tightened, stat) || (tightened.mode & 0o777) !== 0o600) throw changed();
100
+ }
101
+ return true;
102
+ }
103
+
104
+ function secureManagedDatabaseFile(dbPath, create, readOnly = false) {
105
+ if (!secureOwnedSqliteFile(dbPath, { create, readOnly })) {
106
+ throw new CliError('NOT_FOUND', `Project database does not exist: ${dbPath}`);
107
+ }
108
+ for (const suffix of ['-wal', '-shm', '-journal']) {
109
+ secureOwnedSqliteFile(`${dbPath}${suffix}`, { readOnly, optional: true });
110
+ }
111
+ }
112
+
20
113
  export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogText }) {
21
114
  let projectMigrationFanoutDepth = 0;
22
115
  const migratedRegisteredProjectDbs = new Set();
23
116
 
24
117
  function connect(ctx, options = {}) {
25
- if (options.create === false && !fs.existsSync(ctx.dbPath)) {
26
- throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
118
+ let dbPath = ctx.dbPath;
119
+ if (managedDatabase(ctx)) {
120
+ const rootWasMissing = options.create !== false && !fs.existsSync(ctx.root);
121
+ const pendingDefault = rootWasMissing ? path.join(privateProjectStateDir(ctx.root), 'mesh.db') : null;
122
+ if (options.create !== false && !fs.existsSync(ctx.root)) {
123
+ fs.mkdirSync(ctx.root, { recursive: true });
124
+ }
125
+ if (rootWasMissing && path.resolve(dbPath) === pendingDefault) {
126
+ dbPath = path.join(projectStateDir(ctx.root), 'mesh.db');
127
+ }
128
+ if (rootWasMissing && path.resolve(ctx.dbPath) === pendingDefault &&
129
+ projectStateDir(ctx.root) === privateProjectStateDir(ctx.root)) {
130
+ ensurePrivateProjectStateDir(ctx.root, { create: true });
131
+ }
132
+ const resolved = resolveProjectDatabase({
133
+ root: ctx.root,
134
+ db: dbPath,
135
+ createStateDir: options.create !== false,
136
+ createDatabaseParents: options.create !== false
137
+ });
138
+ dbPath = resolved.db;
139
+ if (options.create === false) secureExistingProjectStateDirectory(resolved.root);
140
+ secureManagedDatabaseFile(dbPath, options.create !== false);
141
+ } else {
142
+ if (options.create === false && !fs.existsSync(dbPath)) {
143
+ throw new CliError('NOT_FOUND', `Project database does not exist: ${dbPath}`);
144
+ }
145
+ if (options.create !== false) fs.mkdirSync(path.dirname(dbPath), { recursive: true });
27
146
  }
28
- if (options.create !== false) fs.mkdirSync(path.dirname(ctx.dbPath), { recursive: true });
29
- const db = new DatabaseSync(ctx.dbPath, { timeout: 5000 });
147
+ const db = new DatabaseSync(dbPath, { timeout: 5000 });
30
148
  db.exec('PRAGMA busy_timeout = 5000;');
31
149
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
32
150
  db.exec('PRAGMA synchronous = NORMAL;');
33
151
  db.exec('PRAGMA wal_autocheckpoint = 1000;');
34
152
  db.exec('PRAGMA foreign_keys = ON;');
35
- initSchemaWithBackup(db, ctx.dbPath, {
153
+ initSchemaWithBackup(db, dbPath, {
36
154
  beforePostMigrationIndexes: dedupePeerBindings
37
155
  });
38
156
  if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
@@ -40,10 +158,15 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
40
158
  }
41
159
 
42
160
  function connectReadOnly(ctx) {
43
- if (!fs.existsSync(ctx.dbPath) || !fs.statSync(ctx.dbPath).isFile()) {
44
- throw new CliError('NOT_FOUND', `Project database does not exist: ${ctx.dbPath}`);
161
+ const managed = managedDatabase(ctx);
162
+ const dbPath = managed
163
+ ? resolveProjectDatabase({ root: ctx.root, db: ctx.dbPath, createStateDir: false }).db
164
+ : ctx.dbPath;
165
+ if (!fs.existsSync(dbPath) || !fs.statSync(dbPath).isFile()) {
166
+ throw new CliError('NOT_FOUND', `Project database does not exist: ${dbPath}`);
45
167
  }
46
- return new DatabaseSync(ctx.dbPath, { timeout: 5000, readOnly: true });
168
+ if (managed) secureManagedDatabaseFile(dbPath, false, true);
169
+ return new DatabaseSync(dbPath, { timeout: 5000, readOnly: true });
47
170
  }
48
171
 
49
172
  function migrateRegisteredProjectDbs(ctx) {
@@ -57,7 +180,7 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
57
180
  try {
58
181
  resolved = resolveProjectDatabase({
59
182
  root: project.root,
60
- db: project.db || path.join(project.root, '.hello-cc', 'mesh.db'),
183
+ db: project.db || path.join(projectStateDir(project.root), 'mesh.db'),
61
184
  createStateDir: false
62
185
  });
63
186
  } catch (err) {
@@ -77,6 +200,8 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
77
200
  } catch {}
78
201
  let db = null;
79
202
  try {
203
+ secureExistingProjectStateDirectory(root);
204
+ secureManagedDatabaseFile(dbPath, false);
80
205
  db = new DatabaseSync(dbPath, { timeout: 5000 });
81
206
  db.exec('PRAGMA busy_timeout = 5000;');
82
207
  execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
package/lib/db/schema.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import { aggregateCleanupFailure } from '../shared/cleanup-error.mjs';
2
+ import { automaticResultKey } from '../shared/automatic-result-key.mjs';
2
3
  import { CliError } from '../shared/errors.mjs';
3
4
 
4
5
  export const DB_SCHEMA_VERSION = 7;
6
+ const AUTOMATIC_RESULT_SCRUB_KEY = 'automatic_result_content_scrub_v1';
5
7
 
6
8
  const sleepBuffer = new SharedArrayBuffer(4);
7
9
  const sleepView = new Int32Array(sleepBuffer);
@@ -125,6 +127,63 @@ export function initSchema(db, {
125
127
  runSchemaMigrations(db);
126
128
  if (typeof beforePostMigrationIndexes === 'function') beforePostMigrationIndexes(db);
127
129
  createPostMigrationIndexes(db);
130
+ scrubAutomaticTaskResults(db);
131
+ }
132
+
133
+ function scrubAutomaticTaskResults(db) {
134
+ const marker = db.prepare('SELECT 1 FROM meta WHERE key = ?');
135
+ if (marker.get(AUTOMATIC_RESULT_SCRUB_KEY)) return;
136
+ tx(db, () => {
137
+ if (marker.get(AUTOMATIC_RESULT_SCRUB_KEY)) return;
138
+ const read = db.prepare("SELECT id,payload,created_at FROM events WHERE type = 'task.result.recorded' AND id > ? ORDER BY id LIMIT 100");
139
+ const update = db.prepare('UPDATE events SET payload = ? WHERE id = ?');
140
+ let lastId = 0;
141
+ while (true) {
142
+ const rows = read.all(lastId);
143
+ if (!rows.length) break;
144
+ for (const row of rows) {
145
+ let record;
146
+ try { record = JSON.parse(row.payload); } catch { continue; }
147
+ if (record?.source !== 'executor' || record.stage !== 'local' ||
148
+ !['command', 'diff'].includes(record.kind) ||
149
+ typeof record.executor_id !== 'string' || !record.executor_id ||
150
+ typeof record.thread_id !== 'string' || !record.thread_id ||
151
+ typeof record.turn_id !== 'string' || !record.turn_id ||
152
+ typeof record.result_key !== 'string') continue;
153
+ // Legacy Web observer keys identify these records more precisely than
154
+ // source/kind/turn metadata, which an explicit result may also carry.
155
+ const commandKey = `${record.executor_id}:${record.turn_id}:${record.item_id}:command`;
156
+ const diffPrefix = `${record.executor_id}:${record.turn_id}:diff:`;
157
+ const legacyCommand = record.kind === 'command' && typeof record.item_id === 'string' &&
158
+ record.item_id && record.result_key === commandKey;
159
+ const legacyDiff = record.kind === 'diff' && record.title === 'Turn changes awaiting review' &&
160
+ record.result_key.startsWith(diffPrefix) &&
161
+ /^[a-f0-9]{64}$/.test(record.result_key.slice(diffPrefix.length));
162
+ if (!legacyCommand && !legacyDiff) continue;
163
+ const executorId = typeof record.executor_id === 'string' ? record.executor_id : null;
164
+ const turnId = typeof record.turn_id === 'string' ? record.turn_id : null;
165
+ const itemId = typeof record.item_id === 'string' ? record.item_id : null;
166
+ // Distinct old diff hashes may become the same structural key. Keep the
167
+ // historical rows; result-key lookup already chooses the latest ID.
168
+ const resultKey = automaticResultKey({ executorId,
169
+ threadId: typeof record.thread_id === 'string' ? record.thread_id : null,
170
+ turnId, kind: record.kind, itemId: record.kind === 'command' ? itemId : null });
171
+ update.run(JSON.stringify({
172
+ result_id: record.result_id, result_key: resultKey,
173
+ peer: record.peer, task_id: record.task_id, stage: 'local', status: record.status,
174
+ kind: record.kind, source: 'executor',
175
+ title: record.kind === 'command' ? 'Command execution' : 'Turn changes awaiting review',
176
+ details: '', evidence: [], command: '', diff: '',
177
+ executor_id: executorId, thread_id: record.thread_id, turn_id: turnId,
178
+ item_id: record.kind === 'command' ? itemId : null,
179
+ exit_code: Number.isSafeInteger(record.exit_code) ? record.exit_code : null,
180
+ created_at: row.created_at
181
+ }), row.id);
182
+ }
183
+ lastId = rows.at(-1).id;
184
+ }
185
+ db.prepare('INSERT INTO meta(key,value) VALUES (?,?)').run(AUTOMATIC_RESULT_SCRUB_KEY, '1');
186
+ });
128
187
  }
129
188
 
130
189
  function createBaseSchema(db) {
package/lib/discover.mjs CHANGED
@@ -11,6 +11,7 @@ import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import os from 'node:os';
13
13
  import { spawnSync } from 'node:child_process';
14
+ import { providerSessionPeerId } from './core/peers/session.mjs';
14
15
 
15
16
  // ─── Root detection ──────────────────────────────────────────────────────────
16
17
 
@@ -33,9 +34,8 @@ function fsExists(p) {
33
34
  * Derive a stable peer ID.
34
35
  *
35
36
  * Stability contract:
36
- * - `claude --resume <old-id>` → claude-<old-id[:8]> (same across restarts)
37
- * - `codex resume <id>` → codex-<id[:8]> (same across restarts)
38
- * - fresh session → <kind>-<sessionId[:8]> (unique per session)
37
+ * - provider session ID/name → the same full-value hash used by hooks/shims
38
+ * - fresh session → the same stable provider identity
39
39
  */
40
40
  export function derivePeerId(kind, sessionId, resumeId, explicitPeer = null) {
41
41
  if (explicitPeer) return explicitPeer;
@@ -44,7 +44,7 @@ export function derivePeerId(kind, sessionId, resumeId, explicitPeer = null) {
44
44
  if (process.env.HCC_PEER) return process.env.HCC_PEER;
45
45
 
46
46
  const base = resumeId || sessionId;
47
- if (base && base.length >= 4) return `${kind}-${base.slice(0, 8)}`;
47
+ if (base) return providerSessionPeerId(kind, base);
48
48
 
49
49
  // Fallback: tty device number or pid
50
50
  const ttyNum = getTtyNum();
package/lib/guidance.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { resolveProjectDatabase } from './runtime/project-path.mjs';
3
4
 
4
5
  export function guidanceMarkdown() {
5
6
  return `# hello-cc Coordination Rules
@@ -84,8 +85,8 @@ Before stopping:
84
85
  }
85
86
 
86
87
  export function writeGuidance(root) {
87
- const guidePath = path.join(root, '.hello-cc', 'HCC.md');
88
- fs.mkdirSync(path.dirname(guidePath), { recursive: true });
88
+ const stateDir = resolveProjectDatabase({ root, createStateDir: true }).stateDir;
89
+ const guidePath = path.join(stateDir, 'HCC.md');
89
90
  const content = guidanceMarkdown();
90
91
  fs.writeFileSync(guidePath, content);
91
92
  const clause = `\n<!-- hello-cc:start -->\n\n${content}\n<!-- hello-cc:end -->\n`;
@@ -1,3 +1,7 @@
1
+ import { createMcpFormValidator } from './mcp-elicitation.mjs';
2
+ import { createMcpUrlValidator } from './mcp-url-elicitation.mjs';
3
+
4
+ const mcpForms = createMcpFormValidator(), mcpUrls = createMcpUrlValidator();
1
5
  // Shared validation for the official App Server command/file/permissions/input
2
6
  // server requests. Human responses may narrow a request, never widen it.
3
7
  export const CODEX_INTERACTION_METHODS = new Set([
@@ -37,20 +41,18 @@ function subset(selected, requested) {
37
41
  fail('Granted permissions must be an exact subset of the requested permissions');
38
42
  }
39
43
  }
40
- // Codex 0.144.6 hosts MCP tool approvals as an empty form elicitation.
41
- // Return the official action/content shape. Do not persist permission in _meta,
42
- // and do not pretend arbitrary form fields or URL authentication were answered.
44
+ // Reply only to the correlated request. URL acceptance has no form content or
45
+ // authentication result; the MCP server owns completion of the external flow.
43
46
  function mcpResponse(request, input) {
44
- if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('An MCP approval decision is required');
47
+ if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('An MCP decision is required');
45
48
  if (input.decision !== 'accept') return { action: input.decision };
46
- const params = request.params, schema = params?.requestedSchema;
47
- if (params?.mode !== 'form' || params?._meta?.codex_approval_kind !== 'mcp_tool_call' ||
48
- !object(schema) || schema.type !== 'object' || !object(schema.properties) ||
49
- Object.keys(schema.properties).length || (schema.required?.length || 0) ||
50
- Object.keys(schema).some(key => !['type', 'properties', 'required', 'additionalProperties'].includes(key))) {
51
- fail('Only an explicit empty-form MCP tool approval is supported; decline or cancel other elicitations');
49
+ if (request.params?.mode === 'url') {
50
+ mcpUrls.describe(request.params);
51
+ if (input.content !== undefined || input.answers !== undefined || input.permissions !== undefined) fail('MCP URL responses cannot contain form content or permissions');
52
+ return { action: 'accept' };
52
53
  }
53
- return { action: 'accept', content: {} };
54
+ const { fields } = mcpForms.describe(request.params);
55
+ return { action: 'accept', content: mcpForms.validate(request.params, input.content === undefined && !fields.length ? {} : input.content) };
54
56
  }
55
57
 
56
58
  function permissionsResponse(request, input) {
@@ -153,18 +153,12 @@ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000
153
153
  dispose() {
154
154
  if (disposed) return;
155
155
  retiring = true;
156
- try {
157
- owned(db => {
158
- peers.touchPeer(db, peer, 'exited');
159
- db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ? AND runtime_target = ?').run(now(), peer, owner);
160
- addEvent(db, 'dsh.cordis.closed', peer, null, { session_id: sessionId });
161
- }, true, true);
162
- disposed = true;
163
- } catch (error) {
164
- // A lost generation cannot be retried; storage failures can be.
165
- if (error?.code === 'DSH_COLLABORATION_CONFLICT') disposed = true;
166
- throw error;
167
- }
156
+ owned(db => {
157
+ peers.touchPeer(db, peer, 'exited');
158
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ? AND runtime_target = ?').run(now(), peer, owner);
159
+ addEvent(db, 'dsh.cordis.closed', peer, null, { session_id: sessionId });
160
+ }, true, true);
161
+ disposed = true;
168
162
  }
169
163
  };
170
164
  }
@@ -10,8 +10,20 @@ export const name = 'hello-cc-dsh-collaboration';
10
10
  export const inject = ['agents', 'tools', 'sessions'];
11
11
  export const DSH_CORDIS_VERSION = '0.2.0-rc.2';
12
12
 
13
- const failedRetirements = new Map();
14
- const disposedAgents = new WeakSet();
13
+ // A failed retirement is retried only after exact Agent disposal, or by that
14
+ // same Agent after its previous plugin context has been disposed.
15
+ const retirementRegistryKey = Symbol.for('@logicseek/hello-cc/dsh-cordis/retirements/v1');
16
+ if (!Object.hasOwn(globalThis, retirementRegistryKey)) {
17
+ Object.defineProperty(globalThis, retirementRegistryKey, {
18
+ value: Object.freeze({ failedRetirements: new Map(), disposedAgents: new WeakSet() })
19
+ });
20
+ }
21
+ const retirementRegistry = globalThis[retirementRegistryKey];
22
+ if (!(retirementRegistry?.failedRetirements instanceof Map) ||
23
+ !(retirementRegistry?.disposedAgents instanceof WeakSet)) {
24
+ throw new Error('Invalid hello-cc Cordis retirement registry');
25
+ }
26
+ const { failedRetirements, disposedAgents } = retirementRegistry;
15
27
 
16
28
  function retire(agent, state, { agentDisposed = false, contextDisposed = false }, forget) {
17
29
  let entry = failedRetirements.get(agent);
@@ -22,17 +34,9 @@ function retire(agent, state, { agentDisposed = false, contextDisposed = false }
22
34
  if (agentDisposed) entry.agentDisposed = true;
23
35
  if (contextDisposed) entry.contextDisposed = true;
24
36
  }
25
- try {
26
- entry.state.dispose();
27
- entry.forget();
28
- failedRetirements.delete(agent);
29
- } catch (error) {
30
- if (error?.code === 'DSH_COLLABORATION_CONFLICT') {
31
- entry.forget();
32
- failedRetirements.delete(agent);
33
- }
34
- throw error;
35
- }
37
+ entry.state.dispose();
38
+ entry.forget();
39
+ failedRetirements.delete(agent);
36
40
  }
37
41
 
38
42
  function retryRetirements(candidate) {
@@ -8,6 +8,8 @@ import { CliError } from '../shared/errors.mjs';
8
8
  import { withFileLock } from '../shared/file-lock.mjs';
9
9
  import { commandPath } from '../cli-runtime.mjs';
10
10
  import { shellQuoteArg } from '../format.mjs';
11
+ import { resolveProjectDatabase, ensurePrivateProjectStateSubdirectory } from '../runtime/project-path.mjs';
12
+ import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
11
13
 
12
14
  export const DSH_BASELINE_VERSION = '0.2.0-rc.2';
13
15
  export const DSH_INSTALL_HINT = `npm install -g @deepseek-ai/dsh@${DSH_BASELINE_VERSION}`;
@@ -42,16 +44,14 @@ function integrationPaths(ctx, create = false) {
42
44
  try { root = fs.realpathSync(ctx.root); }
43
45
  catch { throw new CliError('BAD_ARGS', `Project root does not exist: ${ctx.root}`); }
44
46
  if (!fs.statSync(root).isDirectory()) throw new CliError('BAD_ARGS', `Project root is not a directory: ${root}`);
45
- const stateDir = path.join(root, '.hello-cc');
47
+ const stateDir = resolveProjectDatabase({ root, createStateDir: create }).stateDir;
46
48
  const directory = path.join(stateDir, 'dsh');
49
+ // DSH artifacts are opened and renamed by pathname. Their parent must be
50
+ // owned and non-rebindable by another OS user before either operation.
51
+ ensurePrivateProjectStateSubdirectory(root, 'dsh', { create });
47
52
  const parents = [];
48
53
  for (const dir of [root, stateDir, directory]) {
49
- let stat = lstat(dir);
50
- if (!stat && create) {
51
- try { fs.mkdirSync(dir, { mode: 0o700 }); }
52
- catch (error) { if (error.code !== 'EEXIST') throw error; }
53
- stat = lstat(dir);
54
- }
54
+ const stat = lstat(dir);
55
55
  if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) {
56
56
  throw forbidden(dir, 'expected an ordinary directory, found a symlink or other object');
57
57
  }
@@ -75,8 +75,11 @@ function readArtifact(paths, name) {
75
75
  const file = path.join(paths.directory, name);
76
76
  const stat = lstat(file);
77
77
  if (!stat) return null;
78
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
79
- throw forbidden(file, 'expected an ordinary file with no symlink or hard-link alias');
78
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
79
+ (typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
80
+ (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) ||
81
+ unsafeDirectoryAcl(file, stat)) {
82
+ throw forbidden(file, 'expected an owned private file with no symlink or hard-link alias');
80
83
  }
81
84
  const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
82
85
  try {