@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.4
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 +229 -108
- package/README.md +1 -10
- package/README.zh-CN.md +1 -8
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +13 -28
- package/docs/commands.zh-CN.md +11 -20
- package/docs/dsh.md +5 -3
- package/docs/dsh.zh-CN.md +6 -4
- package/docs/macos-process-identity.md +15 -0
- package/docs/native.md +176 -6
- package/docs/native.zh-CN.md +138 -3
- package/docs/web-handoff.zh-CN.md +107 -5
- package/lib/cli/app.mjs +9 -6
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/gc.mjs +14 -12
- package/lib/cli/commands/hook.mjs +16 -3
- package/lib/cli/commands/install.mjs +37 -9
- package/lib/cli/commands/lock.mjs +3 -1
- package/lib/cli/commands/misc.mjs +105 -13
- package/lib/cli/commands/native.mjs +41 -48
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/gc-plan.mjs +5 -0
- package/lib/core/coordination/locks.mjs +3 -1
- package/lib/core/peers/evidence.mjs +21 -1
- package/lib/core/peers/tmux-safety.mjs +4 -0
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +67 -21
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +20 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/dsh-collaboration.mjs +3 -4
- package/lib/integrations/dsh.mjs +4 -2
- package/lib/integrations/native/claude.mjs +53 -9
- package/lib/integrations/native/codex.mjs +75 -15
- package/lib/integrations/native/dsh-acp.mjs +77 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +16 -3
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +67 -28
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/client.mjs +18 -1
- package/lib/runtime/native/client.mjs +12 -2
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +346 -52
- package/lib/runtime/native/store.mjs +75 -21
- package/lib/runtime/project-path.mjs +24 -5
- package/lib/runtime/projects.mjs +3 -0
- package/lib/runtime/state.mjs +61 -26
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/redact.mjs +2 -1
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +2005 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +104 -33
- package/lib/web/codex-sessions.mjs +60 -11
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +22 -4
- package/lib/web/http-routes.mjs +237 -24
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +370 -21
- package/lib/web/project-contexts.mjs +235 -42
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +134 -82
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +76 -27
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/lib/web/tmux-sessions.mjs +71 -7
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +81 -73
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +286 -8
- package/lib/web/ui-interactions.mjs +6 -4
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +115 -51
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +29 -1969
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +9 -2
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/helpers/gc-clock-observation.mjs +33 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +284 -25
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +655 -188
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +22 -7
- package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +152 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-native-retry-checks.mjs +177 -0
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +438 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
3
|
import { CliError } from '../../shared/errors.mjs';
|
|
6
4
|
import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
|
|
7
5
|
import { printResult } from '../../format.mjs';
|
|
8
|
-
import {
|
|
9
|
-
import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
|
|
10
|
-
import { openPrivateAppendFile } from '../../runtime/private-file.mjs';
|
|
6
|
+
import { ensureNativeRuntime } from '../../runtime/native/launcher.mjs';
|
|
11
7
|
import { nativeRequest } from '../../runtime/native/client.mjs';
|
|
12
8
|
import { startNativeService } from '../../runtime/native/service.mjs';
|
|
13
9
|
|
|
14
10
|
export function nativeHelp() {
|
|
15
11
|
console.log(`Usage: hcc native up|status|down
|
|
16
|
-
hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last]
|
|
17
|
-
hcc native
|
|
12
|
+
hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last] [--sandbox read-only|workspace-write]
|
|
13
|
+
hcc native fork --parent NAME --peer NEW_NAME [--model MODEL] [--binary PATH]
|
|
14
|
+
hcc native send --peer NAME --body TEXT [--from NAME] [--task ID] [--submission-id ID]
|
|
18
15
|
hcc native events --peer NAME [--after ID]
|
|
19
16
|
hcc native deliveries [--peer NAME]
|
|
20
17
|
hcc native requests --peer NAME
|
|
@@ -24,69 +21,51 @@ export function nativeHelp() {
|
|
|
24
21
|
|
|
25
22
|
Native workers share the project task/message/lock bus. Only HCC-owned saved
|
|
26
23
|
sessions can resume. Existing TUI/Desktop sessions keep their own transport.
|
|
24
|
+
Use a unique --submission-id for each new message and reuse it only for an
|
|
25
|
+
exact retry after an uncertain receipt.
|
|
27
26
|
Providers retain their permission checks; interactive requests await an explicit Web or local response.
|
|
27
|
+
Codex alone accepts --sandbox. New sessions default to workspace-write; resumed and forked sessions inherit their saved policy.
|
|
28
|
+
read-only disables permission escalation and HCC scoped MCP tools. It is a provider file sandbox, not an all-tools/network guarantee.
|
|
28
29
|
Claude workers require the optional @anthropic-ai/claude-agent-sdk package.
|
|
29
30
|
dsh workers use the ACP profile, not its Web-service terminal.`);
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export function createNativeCommands(deps) {
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const paths = nativePaths(ctx, { create: true });
|
|
41
|
-
let log;
|
|
42
|
-
let child;
|
|
43
|
-
try {
|
|
44
|
-
log = openPrivateAppendFile(paths.log);
|
|
45
|
-
child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
|
|
46
|
-
cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
|
|
47
|
-
});
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
|
|
50
|
-
throw new CliError('NATIVE_STATE_UNSAFE', error.message);
|
|
51
|
-
}
|
|
52
|
-
throw error;
|
|
53
|
-
} finally {
|
|
54
|
-
if (log !== undefined) fs.closeSync(log);
|
|
34
|
+
function requireSandboxRuntime(runtime) {
|
|
35
|
+
if (runtime?.sandboxPolicyVersion !== 1) {
|
|
36
|
+
throw new CliError('NATIVE_SANDBOX_RUNTIME_UNSUPPORTED',
|
|
37
|
+
'This native runtime cannot preserve Codex sandbox policies. Stop the project native runtime after its work has finished, then start it with this HCC version. No worker was created.');
|
|
55
38
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const deadline = Date.now() + 10000;
|
|
60
|
-
while (Date.now() < deadline) {
|
|
61
|
-
if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
|
|
62
|
-
if (readNativePointer(ctx)) {
|
|
63
|
-
try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
|
|
64
|
-
}
|
|
65
|
-
await delay(100);
|
|
39
|
+
if (typeof runtime?.generation !== 'string' ||
|
|
40
|
+
!runtime.generation.trim() || runtime.generation.length > 128) {
|
|
41
|
+
throw new CliError('NATIVE_OWNER_CHANGED', 'Native runtime did not report a verifiable generation. No worker was created.');
|
|
66
42
|
}
|
|
67
|
-
throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
|
|
68
43
|
}
|
|
69
|
-
|
|
70
44
|
async function cmdNative(ctx, args) {
|
|
71
45
|
const command = args[0];
|
|
72
46
|
if (!command || wantsHelp(args)) return nativeHelp();
|
|
73
47
|
const opts = parseOpts(args.slice(1));
|
|
74
48
|
const allowed = {
|
|
75
49
|
up: [], serve: [], down: [], status: [],
|
|
76
|
-
start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume'],
|
|
77
|
-
send: ['peer', 'body', 'from', 'task'],
|
|
50
|
+
start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume', 'sandbox'],
|
|
51
|
+
send: ['peer', 'body', 'from', 'task', 'submission-id'],
|
|
78
52
|
events: ['peer', 'after'], deliveries: ['peer'], requests: ['peer'],
|
|
79
53
|
respond: ['peer', 'request', 'decision', 'response-file'],
|
|
54
|
+
fork: ['peer', 'parent', 'model', 'binary'],
|
|
80
55
|
interrupt: ['peer', 'turn'], close: ['peer']
|
|
81
56
|
};
|
|
82
57
|
if (!Object.hasOwn(allowed, command)) throw new CliError('BAD_ARGS', `Unknown native command: ${command}`);
|
|
83
58
|
validateOpts(`native ${command}`, opts, allowed[command]);
|
|
84
|
-
if (['start', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
|
|
59
|
+
if (['start', 'fork', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
|
|
85
60
|
if (command === 'start' && !opts.provider) throw new CliError('BAD_ARGS', 'Missing --provider');
|
|
86
61
|
if (command === 'start' && opts.provider === 'claude' && opts.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
|
|
62
|
+
if (command === 'start' && opts.sandbox !== undefined &&
|
|
63
|
+
(opts.provider !== 'codex' || !['read-only', 'workspace-write'].includes(opts.sandbox))) {
|
|
64
|
+
throw new CliError('BAD_ARGS', '--sandbox accepts read-only or workspace-write for Codex only');
|
|
65
|
+
}
|
|
87
66
|
if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
|
|
88
67
|
let result;
|
|
89
|
-
if (command === 'up') result = await
|
|
68
|
+
if (command === 'up') result = await ensureNativeRuntime(ctx);
|
|
90
69
|
else if (command === 'serve') {
|
|
91
70
|
const service = await startNativeService(ctx, deps);
|
|
92
71
|
const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
|
|
@@ -94,14 +73,28 @@ export function createNativeCommands(deps) {
|
|
|
94
73
|
process.once('SIGINT', shutdown);
|
|
95
74
|
return;
|
|
96
75
|
} else if (command === 'start') {
|
|
97
|
-
await
|
|
76
|
+
const runtime = await ensureNativeRuntime(ctx);
|
|
77
|
+
if (opts.provider === 'codex') requireSandboxRuntime(runtime);
|
|
98
78
|
result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
|
|
99
|
-
cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume
|
|
79
|
+
cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume,
|
|
80
|
+
...(opts.sandbox !== undefined ? { sandbox: opts.sandbox } : {}) },
|
|
81
|
+
opts.provider === 'codex' ? { expectedGeneration: runtime.generation } : undefined);
|
|
82
|
+
} else if (command === 'fork') {
|
|
83
|
+
if (!opts.parent) throw new CliError('BAD_ARGS', 'Missing --parent');
|
|
84
|
+
const runtime = await nativeRequest(ctx, 'GET', '/status');
|
|
85
|
+
requireSandboxRuntime(runtime);
|
|
86
|
+
result = await nativeRequest(ctx, 'POST', '/fork', { parent: opts.parent, peer: opts.peer, model: opts.model, binary: opts.binary },
|
|
87
|
+
{ expectedGeneration: runtime.generation });
|
|
100
88
|
} else if (command === 'send') {
|
|
101
89
|
const taskId = intOpt(opts, 'task', null);
|
|
102
90
|
if (taskId !== null && taskId < 1) throw new CliError('BAD_ARGS', 'Task ID must be positive');
|
|
91
|
+
const submissionId = opts['submission-id'];
|
|
92
|
+
if (submissionId !== undefined && !/^[A-Za-z0-9_-]{8,100}$/.test(submissionId)) {
|
|
93
|
+
throw new CliError('BAD_ARGS', 'Submission ID must contain 8 to 100 letters, digits, underscores, or hyphens');
|
|
94
|
+
}
|
|
103
95
|
const sender = deps.resolveCurrentPeer(ctx, opts, 'from', 'shell').id;
|
|
104
|
-
result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId
|
|
96
|
+
result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId,
|
|
97
|
+
...(submissionId ? { submissionId } : {}) });
|
|
105
98
|
} else if (command === 'events') {
|
|
106
99
|
const after = intOpt(opts, 'after', 0);
|
|
107
100
|
if (after < 0) throw new CliError('BAD_ARGS', 'Event cursor must be nonnegative');
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
RUNTIME_SESSION_MUTATION_TIMEOUT_MS,
|
|
5
5
|
RUNTIME_SESSION_START_TIMEOUT_MS
|
|
6
6
|
} from '../../runtime/client.mjs';
|
|
7
|
+
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
7
8
|
|
|
8
9
|
export function createPeerCommands(deps) {
|
|
9
10
|
const {
|
|
@@ -75,6 +76,7 @@ async function peerStart(ctx, args) {
|
|
|
75
76
|
const kind = inferPeerKind(id, opts.kind, firstCommand);
|
|
76
77
|
const role = opts.role || 'peer';
|
|
77
78
|
const cwd = path.resolve(opts.cwd || ctx.root);
|
|
79
|
+
const cwdIdentity = captureSelectedCwdSnapshot(cwd);
|
|
78
80
|
const built = buildPeerCommand(id, kind, opts, cmdArgs);
|
|
79
81
|
const command = built.command;
|
|
80
82
|
const binding = { ...built.binding, transport: 'tmux', runtime_session_id: id };
|
|
@@ -139,6 +141,7 @@ async function peerStart(ctx, args) {
|
|
|
139
141
|
role,
|
|
140
142
|
command,
|
|
141
143
|
cwd,
|
|
144
|
+
cwdIdentity,
|
|
142
145
|
binding,
|
|
143
146
|
env: childSessionEnv(),
|
|
144
147
|
restartOnEnvChange: Boolean(opts['restart-env']),
|
package/lib/cli/commands/run.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
10
9
|
import { CliError } from '../../shared/errors.mjs';
|
|
10
|
+
import { spawnPinnedCwdProcess, spawnPinnedCwdPty } from '../../process/pinned-cwd.mjs';
|
|
11
|
+
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
11
12
|
import { tx } from '../../db/schema.mjs';
|
|
12
13
|
import { parseOpts, validateOpts } from '../../cli-args.mjs';
|
|
13
14
|
import { runtimeProcessIdentity } from '../../runtime/state.mjs';
|
|
@@ -57,6 +58,7 @@ async function cmdRun(ctx, args) {
|
|
|
57
58
|
const id = identity.id;
|
|
58
59
|
const role = opts.role || 'peer';
|
|
59
60
|
const cwd = path.resolve(opts.cwd || ctx.cwd);
|
|
61
|
+
const expectedCwdIdentity = captureSelectedCwdSnapshot(cwd);
|
|
60
62
|
const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
|
|
61
63
|
const commandArgs = cmdArgs.length ? cmdArgs.slice(1) : [];
|
|
62
64
|
const binding = bindingFromRun(id, kind, command, commandArgs, 'hcc-run');
|
|
@@ -67,6 +69,7 @@ async function cmdRun(ctx, args) {
|
|
|
67
69
|
kind,
|
|
68
70
|
role,
|
|
69
71
|
cwd,
|
|
72
|
+
expectedCwdIdentity,
|
|
70
73
|
command,
|
|
71
74
|
commandArgs,
|
|
72
75
|
binding,
|
|
@@ -131,10 +134,12 @@ async function cmdRun(ctx, args) {
|
|
|
131
134
|
db.close();
|
|
132
135
|
}
|
|
133
136
|
console.error(redactedLogText(`${CLI_NAME}: running ${id} (${kind}, ${role}) -> ${command} ${commandArgs.join(' ')}`.trim()));
|
|
134
|
-
const child =
|
|
137
|
+
const child = spawnPinnedCwdProcess(command, commandArgs, {
|
|
135
138
|
cwd,
|
|
139
|
+
expectedIdentity: expectedCwdIdentity,
|
|
136
140
|
stdio: 'inherit',
|
|
137
|
-
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath }
|
|
141
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath },
|
|
142
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
138
143
|
});
|
|
139
144
|
const exitCode = await new Promise((resolve) => {
|
|
140
145
|
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
@@ -174,7 +179,7 @@ async function cmdRun(ctx, args) {
|
|
|
174
179
|
* the local terminal and a shared buffer file so hcc web can stream it to
|
|
175
180
|
* browsers. Input from the browser is written to a .in file that we relay.
|
|
176
181
|
*/
|
|
177
|
-
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs, binding, force = false }) {
|
|
182
|
+
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity, command, commandArgs, binding, force = false }) {
|
|
178
183
|
registerProjectActivity(ctx);
|
|
179
184
|
const ptyModule = await import('node-pty');
|
|
180
185
|
const pty = ptyModule.default || ptyModule;
|
|
@@ -265,9 +270,11 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
|
|
|
265
270
|
const cols = process.stdout.columns || 120;
|
|
266
271
|
const rows = process.stdout.rows || 40;
|
|
267
272
|
|
|
268
|
-
const child = pty
|
|
273
|
+
const child = spawnPinnedCwdPty(pty, command, commandArgs, {
|
|
269
274
|
name: 'xterm-256color', cols, rows, cwd,
|
|
270
|
-
|
|
275
|
+
expectedIdentity: expectedCwdIdentity,
|
|
276
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' },
|
|
277
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
271
278
|
});
|
|
272
279
|
const childExit = trackPtyExit(child);
|
|
273
280
|
const wrapperTermination = installPtyTerminationHandlers(child);
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// hcc scan detects provider sessions; the tmux helpers implement DB-proven,
|
|
3
3
|
// evidence-checked tmux GC (plan/validate/execute) for hcc-managed sessions.
|
|
4
4
|
|
|
5
|
-
import path from 'node:path';
|
|
6
5
|
import process from 'node:process';
|
|
7
6
|
import { spawnSync } from 'node:child_process';
|
|
8
7
|
import { CliError } from '../../shared/errors.mjs';
|
|
@@ -527,7 +526,7 @@ async function planTmuxGc(ctx, opts) {
|
|
|
527
526
|
continue;
|
|
528
527
|
}
|
|
529
528
|
const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
|
|
530
|
-
if (hccRoot &&
|
|
529
|
+
if (hccRoot && rootEvidence(ctx.root, hccRoot).state !== 'match') {
|
|
531
530
|
skip('hcc_root_mismatch', { hcc_root: hccRoot });
|
|
532
531
|
continue;
|
|
533
532
|
}
|
package/lib/cli-runtime.mjs
CHANGED
|
@@ -2,15 +2,32 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { projectDbPath } from './runtime/paths.mjs';
|
|
5
|
+
import { captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
|
|
5
6
|
|
|
6
7
|
export function createContext(global = {}, deps = {}) {
|
|
7
8
|
const cwd = deps.cwd || process.cwd();
|
|
8
9
|
const detectRoot = deps.detectRoot;
|
|
9
10
|
if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
|
|
10
|
-
const
|
|
11
|
+
const requestedRoot = detectRoot(cwd, global.root);
|
|
12
|
+
// Freeze an existing root's canonical pathname for this command. A symlink
|
|
13
|
+
// supplied through --root (or HCC_ROOT) may be retargeted after context
|
|
14
|
+
// construction; later state and runtime path derivations must not silently
|
|
15
|
+
// switch projects. Missing roots retain their path for commands that create
|
|
16
|
+
// a project directory.
|
|
17
|
+
let root;
|
|
18
|
+
let initialRootIdentity = null;
|
|
19
|
+
try {
|
|
20
|
+
root = fs.realpathSync.native(requestedRoot);
|
|
21
|
+
initialRootIdentity = captureSelectedCwdSnapshot(root);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
25
|
+
root = requestedRoot;
|
|
26
|
+
}
|
|
11
27
|
const envDb = global.root ? null : process.env.HCC_DB;
|
|
12
28
|
const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
|
|
13
|
-
return { cwd, root, dbPath, json: global.json,
|
|
29
|
+
return { cwd, root, dbPath, json: global.json,
|
|
30
|
+
explicitRoot: Boolean(global.root || process.env.HCC_ROOT), initialRootIdentity };
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export function shellCommand(args, quoteArg) {
|
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { readTx, tx } from '../../db/schema.mjs';
|
|
4
4
|
import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
5
6
|
import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
|
|
6
7
|
|
|
7
8
|
export const HISTORY_GC_BATCH_SIZE = 256;
|
|
@@ -59,6 +60,10 @@ export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options =
|
|
|
59
60
|
result.live += 1;
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
if (isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(owner))) {
|
|
64
|
+
result.deferred += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
62
67
|
if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
|
|
63
68
|
else result.deferred += 1;
|
|
64
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
2
3
|
|
|
3
4
|
export const WHOLE_LOCK_SCOPE = '*';
|
|
4
5
|
|
|
@@ -87,5 +88,6 @@ export function visibleLocksWithEvidence(locks, nowSec, {
|
|
|
87
88
|
evidenceByOwner = new Map()
|
|
88
89
|
} = {}) {
|
|
89
90
|
return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
|
|
90
|
-
evidenceByOwner.get(lock.owner)?.state === 'live'
|
|
91
|
+
evidenceByOwner.get(lock.owner)?.state === 'live' ||
|
|
92
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(lock.owner)));
|
|
91
93
|
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
1
|
+
import { compareProcessIdentity, isProcessIdentityIncompatible } from '../../process/identity.mjs';
|
|
2
2
|
|
|
3
3
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
4
|
+
const INCOMPATIBLE_PROCESS_REASON = 'process_identity_incompatible';
|
|
5
|
+
|
|
6
|
+
export function isPeerEvidenceCompatibilityProtected(evidence = {}) {
|
|
7
|
+
return evidence.state === 'unknown' && evidence.reason === INCOMPATIBLE_PROCESS_REASON;
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export function classifyPeerActivity(peer = {}, options = {}) {
|
|
6
11
|
const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
7
12
|
const graceActive = Boolean(options.graceActive);
|
|
8
13
|
const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
|
|
14
|
+
const evidenceReason = peer.evidence_reason || peer.evidence?.reason;
|
|
9
15
|
const age = Number(peer.age_sec);
|
|
10
16
|
const active = evidenceState === 'live' ||
|
|
17
|
+
isPeerEvidenceCompatibilityProtected({ state: evidenceState, reason: evidenceReason }) ||
|
|
11
18
|
(evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
|
|
12
19
|
return { active, stale: !active };
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
|
|
23
|
+
if (isPeerEvidenceCompatibilityProtected(evidence)) return false;
|
|
16
24
|
if (evidence.state === 'dead') return true;
|
|
17
25
|
if (evidence.state !== 'unknown') return false;
|
|
18
26
|
const nowSec = Number(options.nowSec);
|
|
@@ -37,6 +45,9 @@ function processState(evidence) {
|
|
|
37
45
|
if (evidence.current?.state === 'unknown') return 'unknown';
|
|
38
46
|
if (evidence.current?.state === 'dead') return 'dead';
|
|
39
47
|
if (evidence.current?.state !== 'live') return 'unknown';
|
|
48
|
+
if (isProcessIdentityIncompatible(evidence.storedIdentity, evidence.current.identity)) {
|
|
49
|
+
return 'incompatible';
|
|
50
|
+
}
|
|
40
51
|
return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -48,6 +59,9 @@ function aggregateProcesses(processes) {
|
|
|
48
59
|
if (states.includes('live')) {
|
|
49
60
|
return { state: 'live', reason: 'process_identity_match' };
|
|
50
61
|
}
|
|
62
|
+
if (states.includes('incompatible')) {
|
|
63
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
64
|
+
}
|
|
51
65
|
if (states.every((state) => state === 'dead')) {
|
|
52
66
|
const missing = processes.every((item) => item?.current?.state === 'dead');
|
|
53
67
|
return {
|
|
@@ -81,8 +95,14 @@ function tmuxVerifiedLive(tmux) {
|
|
|
81
95
|
export function resolvePeerEvidence(input = {}) {
|
|
82
96
|
const ordinaryProcess = aggregateProcesses(input.processes);
|
|
83
97
|
if (ordinaryProcess.state === 'live') return ordinaryProcess;
|
|
98
|
+
// A live PID with incomparable Darwin generations proves neither ownership
|
|
99
|
+
// nor exit. Retain that distinction through persisted status and TTL gates.
|
|
100
|
+
if (isPeerEvidenceCompatibilityProtected(ordinaryProcess)) return ordinaryProcess;
|
|
84
101
|
|
|
85
102
|
const tmux = input.tmux?.managed ? input.tmux : null;
|
|
103
|
+
if (ordinaryProcess.state === 'unknown' && tmux && tmuxProcessState(tmux) === 'incompatible') {
|
|
104
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
105
|
+
}
|
|
86
106
|
if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
|
|
87
107
|
return { state: 'live', reason: 'tmux_identity_match' };
|
|
88
108
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
2
2
|
import { tx } from '../../db/schema.mjs';
|
|
3
3
|
import { CliError } from '../../shared/errors.mjs';
|
|
4
|
+
import { isPeerEvidenceCompatibilityProtected } from './evidence.mjs';
|
|
4
5
|
|
|
5
6
|
function completeIdentity(identity) {
|
|
6
7
|
return Boolean(
|
|
@@ -84,6 +85,9 @@ export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, optio
|
|
|
84
85
|
if (subject.owner_evidence?.state === 'live') {
|
|
85
86
|
return { ok: false, reason: 'tmux_owner_process_live' };
|
|
86
87
|
}
|
|
88
|
+
if (isPeerEvidenceCompatibilityProtected(subject.owner_evidence)) {
|
|
89
|
+
return { ok: false, reason: 'tmux_owner_process_incompatible' };
|
|
90
|
+
}
|
|
87
91
|
|
|
88
92
|
const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
|
|
89
93
|
if (dead.ok) return { ok: true, mode: 'dead_process' };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {
|
|
3
|
+
assertSelectedCwdSnapshot,
|
|
4
|
+
captureSelectedCwdSnapshot,
|
|
5
|
+
sameSelectedCwdIdentity
|
|
6
|
+
} from '../../process/selected-cwd-identity.mjs';
|
|
7
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
8
|
+
|
|
9
|
+
export const HOOK_ROOT_IDENTITY_ENV = 'HCC_HOOK_ROOT_IDENTITY';
|
|
10
|
+
|
|
11
|
+
function mismatch() {
|
|
12
|
+
return new CliError('HOOK_ROOT_IDENTITY_MISMATCH',
|
|
13
|
+
'Hook project root no longer matches the provider launch identity');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Carry the launch-time directory identity into short-lived hook children.
|
|
17
|
+
// A pathname alone cannot distinguish a moved project from another directory
|
|
18
|
+
// subsequently installed at the same pathname.
|
|
19
|
+
export function hookRootIdentityValue(root, original = null) {
|
|
20
|
+
const selected = captureSelectedCwdSnapshot(root);
|
|
21
|
+
if (original) {
|
|
22
|
+
if (typeof original.assertUnchanged === 'function') original.assertUnchanged();
|
|
23
|
+
else assertSelectedCwdSnapshot(original);
|
|
24
|
+
if (!sameSelectedCwdIdentity(selected, original)) throw mismatch();
|
|
25
|
+
}
|
|
26
|
+
return JSON.stringify({ version: 1, ...selected });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertHookRootIdentity(root, value) {
|
|
30
|
+
if (value === undefined) return false; // Unmanaged and pre-upgrade hooks have no launch marker.
|
|
31
|
+
try {
|
|
32
|
+
if (typeof value !== 'string' || value.length > 4096) throw mismatch();
|
|
33
|
+
const snapshot = JSON.parse(value);
|
|
34
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot) ||
|
|
35
|
+
snapshot.version !== 1 || snapshot.requested !== path.resolve(root) ||
|
|
36
|
+
typeof snapshot.canonical !== 'string' || !path.isAbsolute(snapshot.canonical) ||
|
|
37
|
+
!snapshot.identity || typeof snapshot.identity !== 'object' ||
|
|
38
|
+
!/^\d+$/.test(snapshot.identity.dev) || !/^\d+$/.test(snapshot.identity.ino) ||
|
|
39
|
+
(snapshot.identity.birthtimeNs !== null && !/^\d+$/.test(snapshot.identity.birthtimeNs))) {
|
|
40
|
+
throw mismatch();
|
|
41
|
+
}
|
|
42
|
+
assertSelectedCwdSnapshot(snapshot);
|
|
43
|
+
return snapshot;
|
|
44
|
+
} catch {
|
|
45
|
+
throw mismatch();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { shortHash } from '../peers/format.mjs';
|
|
5
|
+
import { HOOK_ROOT_IDENTITY_ENV, hookRootIdentityValue } from './hook-root-identity.mjs';
|
|
5
6
|
|
|
6
7
|
export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
7
8
|
export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
|
|
@@ -11,6 +12,7 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
11
12
|
'_',
|
|
12
13
|
'COLUMNS',
|
|
13
14
|
'HCC_DB',
|
|
15
|
+
HOOK_ROOT_IDENTITY_ENV,
|
|
14
16
|
'HCC_NO_AUTO_INSTALL_TMUX',
|
|
15
17
|
'HCC_PEER',
|
|
16
18
|
'HCC_ROOT',
|
|
@@ -41,8 +43,12 @@ const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
|
41
43
|
'SSH_AUTH_SOCK'
|
|
42
44
|
]);
|
|
43
45
|
|
|
44
|
-
export function childSessionEnv(extra = {}, baseEnv = process.env) {
|
|
46
|
+
export function childSessionEnv(extra = {}, baseEnv = process.env, { rootIdentity = null } = {}) {
|
|
45
47
|
const env = { ...(baseEnv || {}), ...extra };
|
|
48
|
+
if (Object.hasOwn(extra, 'HCC_ROOT')) {
|
|
49
|
+
if (extra.HCC_ROOT) env[HOOK_ROOT_IDENTITY_ENV] = hookRootIdentityValue(extra.HCC_ROOT, rootIdentity);
|
|
50
|
+
else delete env[HOOK_ROOT_IDENTITY_ENV];
|
|
51
|
+
}
|
|
46
52
|
delete env[WEB_CHILD_ENV];
|
|
47
53
|
delete env.HCC_RUNTIME_LOCAL_ONLY;
|
|
48
54
|
return env;
|
package/lib/db/connection.mjs
CHANGED
|
@@ -10,7 +10,8 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
10
10
|
import { CliError } from '../shared/errors.mjs';
|
|
11
11
|
import {
|
|
12
12
|
DB_SCHEMA_VERSION,
|
|
13
|
-
execWithBusyRetry
|
|
13
|
+
execWithBusyRetry,
|
|
14
|
+
readSchemaVersion
|
|
14
15
|
} from './schema.mjs';
|
|
15
16
|
import { initSchemaWithBackup } from './migration-backup.mjs';
|
|
16
17
|
import { readProjectRegistry } from '../runtime/projects.mjs';
|
|
@@ -18,6 +19,7 @@ import { resolveProjectDatabase, secureExistingProjectStateDirectory } from '../
|
|
|
18
19
|
import { projectStateDir, legacyProjectStateDir } from '../runtime/paths.mjs';
|
|
19
20
|
import { privateProjectStateDir, ensurePrivateProjectStateDir } from '../runtime/private-state.mjs';
|
|
20
21
|
import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
|
|
22
|
+
import { assertSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
21
23
|
|
|
22
24
|
const MIGRATION_FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
|
|
23
25
|
|
|
@@ -28,16 +30,40 @@ function managedDatabase(ctx) {
|
|
|
28
30
|
try { canonicalRoot = fs.realpathSync.native(requestedRoot); } catch {}
|
|
29
31
|
const requestedDb = path.resolve(ctx.dbPath);
|
|
30
32
|
let canonicalCandidate = requestedDb;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
let parent = path.dirname(requestedDb);
|
|
34
|
+
const missingParents = [];
|
|
35
|
+
for (;;) {
|
|
36
|
+
try {
|
|
37
|
+
canonicalCandidate = path.join(fs.realpathSync.native(parent),
|
|
38
|
+
...missingParents, path.basename(requestedDb));
|
|
39
|
+
break;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error?.code !== 'ENOENT' || path.dirname(parent) === parent) break;
|
|
42
|
+
missingParents.unshift(path.basename(parent));
|
|
43
|
+
parent = path.dirname(parent);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const candidates = new Set([requestedDb, canonicalCandidate]);
|
|
47
|
+
try { candidates.add(fs.realpathSync.native(requestedDb)); } catch {}
|
|
48
|
+
const within = (parent, candidate) => {
|
|
49
|
+
const relative = path.relative(parent, candidate);
|
|
50
|
+
return relative !== '' && relative !== '..' &&
|
|
51
|
+
!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
52
|
+
};
|
|
53
|
+
const managed = [requestedRoot, canonicalRoot].some(root =>
|
|
35
54
|
[legacyProjectStateDir(root), privateProjectStateDir(root)].some(stateDir =>
|
|
36
|
-
[
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
[...candidates].some(db => within(stateDir, db))));
|
|
56
|
+
if (managed) return true;
|
|
57
|
+
// A context may have captured A's local or private .hello-cc DB before its
|
|
58
|
+
// root alias was repointed to B. A reserved state path is never an explicit
|
|
59
|
+
// external DB just because it no longer belongs to the current root.
|
|
60
|
+
const reservedStatePath = db => path.dirname(db).split(path.sep).some(segment =>
|
|
61
|
+
segment.toLowerCase() === '.hello-cc');
|
|
62
|
+
if ([...candidates].some(reservedStatePath)) {
|
|
63
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN',
|
|
64
|
+
`Private database path is not bound to the current project root: ${requestedDb}`);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
41
67
|
}
|
|
42
68
|
|
|
43
69
|
function secureOwnedSqliteFile(file, { create = false, readOnly = false, optional = false } = {}) {
|
|
@@ -115,6 +141,11 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
|
|
|
115
141
|
const migratedRegisteredProjectDbs = new Set();
|
|
116
142
|
|
|
117
143
|
function connect(ctx, options = {}) {
|
|
144
|
+
const existingSchema = options.existingSchema === true;
|
|
145
|
+
if (existingSchema && options.create !== false) {
|
|
146
|
+
throw new TypeError('existingSchema requires create: false');
|
|
147
|
+
}
|
|
148
|
+
if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
118
149
|
let dbPath = ctx.dbPath;
|
|
119
150
|
if (managedDatabase(ctx)) {
|
|
120
151
|
const rootWasMissing = options.create !== false && !fs.existsSync(ctx.root);
|
|
@@ -144,20 +175,35 @@ export function createConnectionHelpers({ now, dedupePeerBindings, redactedLogTe
|
|
|
144
175
|
}
|
|
145
176
|
if (options.create !== false) fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
146
177
|
}
|
|
147
|
-
const db = new DatabaseSync(dbPath, { timeout: 5000 });
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
178
|
+
const db = new DatabaseSync(dbPath, { timeout: existingSchema ? 0 : 5000 });
|
|
179
|
+
try {
|
|
180
|
+
if (existingSchema) {
|
|
181
|
+
// Shutdown may abandon advisory detachment when another writer owns
|
|
182
|
+
// the DB. Never migrate, change journal mode, or retry while exiting.
|
|
183
|
+
db.exec('PRAGMA busy_timeout = 0; PRAGMA foreign_keys = ON;');
|
|
184
|
+
if (readSchemaVersion(db) !== DB_SCHEMA_VERSION) {
|
|
185
|
+
throw new CliError('DB_SCHEMA_NOT_CURRENT', 'Shutdown requires an existing current database schema');
|
|
186
|
+
}
|
|
187
|
+
return db;
|
|
188
|
+
}
|
|
189
|
+
db.exec('PRAGMA busy_timeout = 5000;');
|
|
190
|
+
execWithBusyRetry(db, 'PRAGMA journal_mode = WAL;', { ignoreBusy: true });
|
|
191
|
+
db.exec('PRAGMA synchronous = NORMAL;');
|
|
192
|
+
db.exec('PRAGMA wal_autocheckpoint = 1000;');
|
|
193
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
194
|
+
initSchemaWithBackup(db, dbPath, {
|
|
195
|
+
beforePostMigrationIndexes: dedupePeerBindings
|
|
196
|
+
});
|
|
197
|
+
if (options.migrateRegistered !== false) migrateRegisteredProjectDbs(ctx);
|
|
198
|
+
return db;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
try { db.close(); } catch {}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
158
203
|
}
|
|
159
204
|
|
|
160
205
|
function connectReadOnly(ctx) {
|
|
206
|
+
if (ctx.initialRootIdentity) assertSelectedCwdSnapshot(ctx.initialRootIdentity);
|
|
161
207
|
const managed = managedDatabase(ctx);
|
|
162
208
|
const dbPath = managed
|
|
163
209
|
? resolveProjectDatabase({ root: ctx.root, db: ctx.dbPath, createStateDir: false }).db
|