@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.
- package/CHANGELOG.md +111 -23
- package/README.md +10 -1
- package/README.zh-CN.md +8 -1
- package/docs/commands.md +28 -4
- package/docs/commands.zh-CN.md +20 -4
- package/docs/dsh.md +2 -2
- package/docs/dsh.zh-CN.md +3 -3
- package/docs/native.md +84 -10
- package/docs/native.zh-CN.md +66 -5
- package/docs/web-handoff.zh-CN.md +11 -3
- package/lib/cli/app.mjs +42 -1
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +9 -4
- package/lib/cli/commands/hook.mjs +6 -1
- package/lib/cli/commands/install.mjs +48 -6
- package/lib/cli/commands/lifecycle.mjs +14 -3
- package/lib/cli/commands/native.mjs +16 -9
- package/lib/cli/commands/query.mjs +15 -10
- package/lib/cli/commands/run.mjs +7 -6
- package/lib/core/coordination/results.mjs +27 -10
- package/lib/db/connection.mjs +135 -10
- package/lib/db/schema.mjs +59 -0
- package/lib/discover.mjs +4 -4
- package/lib/guidance.mjs +3 -2
- 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 +12 -9
- package/lib/integrations/hooks.mjs +132 -40
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/peers/identity.mjs +42 -7
- package/lib/process/identity.mjs +15 -14
- package/lib/runtime/buffer-evidence.mjs +2 -1
- package/lib/runtime/native/service.mjs +11 -5
- package/lib/runtime/native/store.mjs +70 -18
- 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 +148 -12
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +6 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +22 -11
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/ui/help.mjs +6 -4
- package/lib/web/codex-app-server.mjs +26 -5
- package/lib/web/codex-sessions.mjs +5 -3
- package/lib/web/cookie-auth.mjs +1 -1
- package/lib/web/external-sessions.mjs +54 -5
- package/lib/web/http-routes.mjs +13 -9
- package/lib/web/http.mjs +20 -2
- package/lib/web/native-sessions.mjs +14 -7
- package/lib/web/peer-actions.mjs +27 -27
- package/lib/web/project-contexts.mjs +2 -3
- package/lib/web/runtime-main.mjs +22 -5
- package/lib/web/runtime.mjs +18 -4
- package/lib/web/session-serialize.mjs +1 -1
- package/lib/web/startup.mjs +115 -22
- package/lib/web/task-results.mjs +20 -17
- package/lib/web/terminal-assets.mjs +18 -0
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +2 -2
- package/lib/web/tmux-stream.mjs +3 -4
- package/lib/web/ui-codex.mjs +161 -32
- package/lib/web/ui-interactions.mjs +137 -4
- package/lib/web/ui-native.mjs +31 -13
- package/lib/web/ui-template.mjs +10 -3
- package/package.json +1 -1
- package/scripts/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-installed-acceptance.mjs +413 -0
- package/scripts/regression.mjs +46 -29
- package/scripts/web-mcp-form-acceptance.mjs +119 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-interaction-acceptance.mjs +77 -10
package/lib/db/connection.mjs
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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, '
|
|
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
|
-
* -
|
|
37
|
-
* -
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
-
//
|
|
41
|
-
//
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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) {
|
package/lib/integrations/dsh.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|