@logicseek/hello-cc 1.0.5 → 1.1.0-rc.3
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 +72 -0
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +9 -0
- package/docs/commands.zh-CN.md +7 -0
- package/docs/macos-process-identity.md +2 -0
- package/docs/native.md +179 -14
- package/docs/native.zh-CN.md +136 -6
- package/docs/web-handoff.zh-CN.md +118 -8
- package/lib/cli/app.mjs +47 -4
- package/lib/cli/commands/coordination.mjs +14 -4
- package/lib/cli/commands/dsh.mjs +1 -1
- package/lib/cli/commands/gc.mjs +22 -15
- package/lib/cli/commands/hook.mjs +19 -3
- package/lib/cli/commands/install.mjs +80 -10
- package/lib/cli/commands/misc.mjs +36 -9
- package/lib/cli/commands/native.mjs +4 -38
- 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/results.mjs +27 -10
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +140 -42
- package/lib/db/schema.mjs +59 -0
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +21 -36
- package/lib/integrations/codex-account.mjs +143 -0
- 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 +16 -11
- package/lib/integrations/mcp-elicitation.mjs +116 -0
- package/lib/integrations/mcp-url-elicitation.mjs +25 -0
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/interactions.mjs +2 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +4 -0
- 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 +2 -1
- package/lib/runtime/client.mjs +9 -0
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +64 -8
- package/lib/runtime/native/store.mjs +83 -58
- 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 +92 -20
- package/lib/runtime/project-trust.mjs +79 -0
- package/lib/runtime/projects.mjs +9 -3
- package/lib/runtime/state-migration.mjs +697 -0
- package/lib/runtime/state.mjs +69 -27
- package/lib/shared/automatic-result-key.mjs +6 -0
- package/lib/shared/json-file.mjs +55 -6
- package/lib/shared/redact.mjs +2 -1
- package/lib/ui/help.mjs +6 -4
- 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 +1997 -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 +111 -37
- package/lib/web/codex-sessions.mjs +61 -12
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +76 -9
- package/lib/web/http-routes.mjs +238 -25
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +323 -14
- package/lib/web/project-contexts.mjs +236 -44
- 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 +132 -80
- 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 +103 -35
- package/lib/web/task-results.mjs +19 -19
- package/lib/web/tls.mjs +84 -33
- package/lib/web/tmux-sessions.mjs +73 -9
- 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 +105 -85
- 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 +280 -8
- package/lib/web/ui-interactions.mjs +107 -6
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -43
- 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 -1968
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- 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/mcp-form-live-fixture.mjs +81 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +611 -0
- 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 +481 -219
- 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 +134 -0
- package/scripts/web-mcp-form-live-acceptance.mjs +368 -0
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +219 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -8,15 +8,32 @@ import { childSessionEnv } from '../core/sessions/launch.mjs';
|
|
|
8
8
|
import { createCodexAppServer } from './codex-app-server.mjs';
|
|
9
9
|
import { waitForLiveProcessIdentity } from '../process/identity.mjs';
|
|
10
10
|
import { createScopedMcpConfig } from '../mcp/scope.mjs';
|
|
11
|
+
import { sameSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
|
|
11
12
|
|
|
12
13
|
// Each structured session owns one executor. Existing TUI sessions are never
|
|
13
14
|
// silently resumed here: provider identity and live-owner evidence fence that.
|
|
14
15
|
export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId,
|
|
15
16
|
connectWebProject, upsertPeer, upsertCanonicalPeerBinding, observePeerEvidence,
|
|
16
17
|
broadcast, closeSessionClients, now, addEvent, adapterFactory = createCodexAppServer,
|
|
17
|
-
waitForOwnerIdentity = waitForLiveProcessIdentity, mcpConfigFactory = createScopedMcpConfig
|
|
18
|
+
waitForOwnerIdentity = waitForLiveProcessIdentity, mcpConfigFactory = createScopedMcpConfig,
|
|
19
|
+
isStopping = () => false }) {
|
|
18
20
|
const startingThreads = new Set();
|
|
19
21
|
|
|
22
|
+
function assertSelectedProject(pctx) {
|
|
23
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
24
|
+
pctx.rootIdentity?.assertUnchanged();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function managedSessionMatchesProject(session, pctx) {
|
|
28
|
+
if (session.root !== pctx.root ||
|
|
29
|
+
(session.ctx?.dbPath && pctx.dbPath && path.resolve(session.ctx.dbPath) !== path.resolve(pctx.dbPath))) return false;
|
|
30
|
+
const owner = session.ctx?.rootIdentity;
|
|
31
|
+
const requested = pctx.rootIdentity;
|
|
32
|
+
return owner || requested
|
|
33
|
+
? Boolean(owner && requested && sameSelectedCwdIdentity(owner, requested))
|
|
34
|
+
: true;
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
function event(session, type, payload) {
|
|
21
38
|
const db = connectWebProject(session.ctx);
|
|
22
39
|
try { addEvent(db, type, session.peerId, null, payload); } finally { db.close(); }
|
|
@@ -28,6 +45,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
28
45
|
throw new CliError('BAD_REQUEST', 'App Server uses the local Codex executable and runtime environment');
|
|
29
46
|
}
|
|
30
47
|
const pctx = input.projectCtx;
|
|
48
|
+
assertSelectedProject(pctx);
|
|
31
49
|
const cwd = pctx.root;
|
|
32
50
|
const mode = input.mode || 'new';
|
|
33
51
|
if (!['new', 'resume'].includes(mode)) throw new CliError('BAD_REQUEST', 'App Server supports new or explicit resume');
|
|
@@ -70,19 +88,21 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
70
88
|
let closeScheduled = false;
|
|
71
89
|
let adapter;
|
|
72
90
|
let ownerIdentity = null;
|
|
73
|
-
try { adapter = adapterFactory({ cwd,
|
|
91
|
+
try { adapter = adapterFactory({ cwd, expectedCwdIdentity: pctx.rootIdentity || pctx.initialRootIdentity,
|
|
92
|
+
changeView: true, env: childSessionEnv({
|
|
74
93
|
HCC_PEER: id, HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1'
|
|
75
|
-
}, process.env), async onSpawn({ pid, executorId }) {
|
|
94
|
+
}, process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }), async onSpawn({ pid, executorId }) {
|
|
76
95
|
const owner = await waitForOwnerIdentity(pid);
|
|
77
96
|
if (owner.state !== 'live' || !owner.identity) throw new CliError('CODEX_OWNER_IDENTITY_UNKNOWN', 'Executor ownership could not be verified');
|
|
97
|
+
assertSelectedProject(pctx);
|
|
78
98
|
ownerIdentity = owner.identity;
|
|
79
99
|
session.mcpCapability = mcpConfigFactory({ root: pctx.root, dbPath: pctx.dbPath,
|
|
80
100
|
peer: id, executorId, ownerIdentity,
|
|
81
101
|
binding: { transport: 'app-server', runtimeSessionId: id } });
|
|
82
102
|
return { mcp_servers: { hello_cc_scoped: session.mcpCapability.config } };
|
|
83
|
-
}, onChange(state) {
|
|
103
|
+
}, onChange(state, event, metadata) {
|
|
84
104
|
session.executorId ||= state.executorId;
|
|
85
|
-
broadcast(session, { type: 'codex_state', state });
|
|
105
|
+
broadcast(session, { type: 'codex_state', state }, event, metadata);
|
|
86
106
|
if (state.status === 'disconnected' && !closeScheduled) {
|
|
87
107
|
closeScheduled = true;
|
|
88
108
|
queueMicrotask(() => { if (adapter) void adapter.close(); });
|
|
@@ -90,6 +110,8 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
90
110
|
if (!registered) return;
|
|
91
111
|
const ended = ['disconnected', 'closed'].includes(state.status);
|
|
92
112
|
if (ended) { session.status = 'exited'; session.exitedAt = now(); session.mcpCapability?.dispose(); }
|
|
113
|
+
if (!ended && session.lastHeartbeatWrite === now()) return;
|
|
114
|
+
session.lastHeartbeatWrite = now();
|
|
93
115
|
const update = connectWebProject(pctx);
|
|
94
116
|
try {
|
|
95
117
|
if (ended) update.prepare("UPDATE peers SET status = 'exited' WHERE id = ?").run(session.peerId);
|
|
@@ -106,7 +128,9 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
106
128
|
const source = await adapter.peekThread(threadId);
|
|
107
129
|
assertProjectThread(pctx, source.thread, threadId);
|
|
108
130
|
}
|
|
131
|
+
assertSelectedProject(pctx);
|
|
109
132
|
const response = threadId ? await adapter.resumeThread(threadId, params) : await adapter.startThread(params);
|
|
133
|
+
assertSelectedProject(pctx);
|
|
110
134
|
const thread = response.thread;
|
|
111
135
|
if (!thread?.id) throw new CliError('CODEX_PROTOCOL_ERROR', 'App Server returned no thread identity');
|
|
112
136
|
if (threadId && thread.id !== threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Resumed thread identity changed');
|
|
@@ -118,6 +142,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
118
142
|
session.executorId = executorState.executorId;
|
|
119
143
|
const save = connectWebProject(pctx);
|
|
120
144
|
try {
|
|
145
|
+
assertSelectedProject(pctx);
|
|
121
146
|
tx(save, () => {
|
|
122
147
|
const owners = save.prepare(`SELECT p.*, b.transport, b.runtime_target FROM peer_bindings b
|
|
123
148
|
JOIN peers p ON p.id = b.peer WHERE b.provider = 'codex' AND b.provider_session_id = ?`).all(thread.id);
|
|
@@ -154,13 +179,18 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
154
179
|
if (session.type !== 'app-server' || session.status !== 'running') {
|
|
155
180
|
throw new CliError('SESSION_NOT_RUNNING', 'A running App Server session is required');
|
|
156
181
|
}
|
|
182
|
+
assertSelectedProject(session.ctx);
|
|
157
183
|
const adapter = session.adapter;
|
|
158
184
|
const threadId = session.binding.provider_session_id;
|
|
159
185
|
if (action === 'read') return adapter.readThread(threadId, { includeTurns: true });
|
|
186
|
+
if (action === 'account') {
|
|
187
|
+
if (input.executorId !== adapter.snapshot().executorId) throw new CliError('CODEX_EXECUTOR_MISMATCH', 'Account view belongs to another executor');
|
|
188
|
+
return adapter.readAccount();
|
|
189
|
+
}
|
|
160
190
|
if (action === 'interrupt') return adapter.interrupt(threadId, String(input.turnId || ''));
|
|
161
191
|
if (action === 'approve') return adapter.approve({ executorId: input.executorId,
|
|
162
192
|
threadId, turnId: input.turnId, requestId: input.requestId, decision: input.decision,
|
|
163
|
-
permissions: input.permissions, scope: input.scope, answers: input.answers });
|
|
193
|
+
permissions: input.permissions, scope: input.scope, answers: input.answers, content: input.content });
|
|
164
194
|
if (!['turn', 'steer'].includes(action)) throw new CliError('BAD_REQUEST', 'Unsupported Codex action');
|
|
165
195
|
const text = String(input.text || '').trim();
|
|
166
196
|
if (!text || text.length > 100000) throw new CliError('BAD_REQUEST', 'A message of 1 to 100000 characters is required');
|
|
@@ -204,8 +234,10 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
204
234
|
closeSessionClients(session);
|
|
205
235
|
}
|
|
206
236
|
function assertProjectThread(pctx, thread, expectedId = null) {
|
|
237
|
+
assertSelectedProject(pctx);
|
|
207
238
|
let matches = false;
|
|
208
239
|
try { matches = typeof thread?.cwd === 'string' && fs.realpathSync(thread.cwd) === fs.realpathSync(pctx.root); } catch {}
|
|
240
|
+
assertSelectedProject(pctx);
|
|
209
241
|
if (!matches || typeof thread?.id !== 'string' || (expectedId && thread.id !== expectedId)) {
|
|
210
242
|
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Codex thread does not belong to this project');
|
|
211
243
|
}
|
|
@@ -220,12 +252,22 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
220
252
|
}
|
|
221
253
|
|
|
222
254
|
async function withHistoryAdapter(pctx, action, fresh = false) {
|
|
255
|
+
assertSelectedProject(pctx);
|
|
223
256
|
const managed = !fresh && [...sessions.values()].find(session => session.type === 'app-server' &&
|
|
224
|
-
session
|
|
225
|
-
if (managed)
|
|
257
|
+
managedSessionMatchesProject(session, pctx) && session.status === 'running' && session.adapter.snapshot().status === 'ready');
|
|
258
|
+
if (managed) {
|
|
259
|
+
const result = await action(managed.adapter);
|
|
260
|
+
assertSelectedProject(pctx);
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
226
263
|
const adapter = adapterFactory({ cwd: pctx.root,
|
|
227
|
-
env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' },
|
|
228
|
-
|
|
264
|
+
env: childSessionEnv({ HCC_ROOT: pctx.root, HCC_DB: pctx.dbPath, HCC_SHIM_NO_ATTACH: '1' },
|
|
265
|
+
process.env, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity }) });
|
|
266
|
+
try {
|
|
267
|
+
const result = await action(adapter);
|
|
268
|
+
assertSelectedProject(pctx);
|
|
269
|
+
return result;
|
|
270
|
+
} finally {
|
|
229
271
|
await adapter.close();
|
|
230
272
|
const state = adapter.snapshot();
|
|
231
273
|
if (state.pid && !state.processExited) {
|
|
@@ -240,13 +282,19 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
240
282
|
throw new CliError('BAD_REQUEST', 'History limit must be 1 to 100 and cursor must be bounded text');
|
|
241
283
|
}
|
|
242
284
|
const result = await withHistoryAdapter(pctx, adapter => adapter.listThreads({ cwd: pctx.root, limit, cursor }));
|
|
285
|
+
assertSelectedProject(pctx);
|
|
243
286
|
const threads = (result.data || []).filter(thread => {
|
|
244
|
-
try { assertProjectThread(pctx, thread); return true; }
|
|
287
|
+
try { assertProjectThread(pctx, thread); return true; }
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error?.code === 'PROJECT_PATH_CHANGED') throw error;
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
245
292
|
}).map(thread => {
|
|
246
|
-
const managed = [...sessions.values()].find(session => session
|
|
293
|
+
const managed = [...sessions.values()].find(session => managedSessionMatchesProject(session, pctx) &&
|
|
247
294
|
session.binding?.provider_session_id === thread.id && session.status === 'running');
|
|
248
295
|
return { ...thread, managedSessionId: managed?.id || null };
|
|
249
296
|
});
|
|
297
|
+
assertSelectedProject(pctx);
|
|
250
298
|
return { threads, nextCursor: result.nextCursor || null };
|
|
251
299
|
}
|
|
252
300
|
|
|
@@ -270,6 +318,7 @@ export function createCodexSessions({ sessions, sessionKey, nextProjectSessionId
|
|
|
270
318
|
const result = await withHistoryAdapter(pctx, async adapter => {
|
|
271
319
|
const source = await adapter.peekThread(threadId);
|
|
272
320
|
assertProjectThread(pctx, source.thread, threadId);
|
|
321
|
+
assertSelectedProject(pctx);
|
|
273
322
|
const forked = await adapter.forkThread(threadId, input.lastTurnId ? { lastTurnId: input.lastTurnId } : {}, input.authorizeMutation);
|
|
274
323
|
assertProjectThread(pctx, forked.thread);
|
|
275
324
|
if (forked.thread.id === threadId) throw new CliError('CODEX_PROTOCOL_ERROR', 'Fork did not create a new thread');
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CliError } from '../shared/errors.mjs';
|
|
4
|
+
|
|
5
|
+
const ignored = new Set(['.git', 'node_modules', '.hcc', '.hello-cc', '.codex', '.claude', '.dsh', '.ssh', '.aws', 'dist', 'build', 'coverage', '.next']);
|
|
6
|
+
const ignoredName = name => ignored.has(name) || name === '.env' || name.startsWith('.env.');
|
|
7
|
+
|
|
8
|
+
function assertBoundRoot(rootIdentity, stat = null) {
|
|
9
|
+
if (!rootIdentity) return;
|
|
10
|
+
rootIdentity.assertUnchanged();
|
|
11
|
+
if (stat && (rootIdentity.identity.dev !== stat.dev.toString() ||
|
|
12
|
+
rootIdentity.identity.ino !== stat.ino.toString() ||
|
|
13
|
+
rootIdentity.identity.birthtimeNs !== (stat.birthtimeNs?.toString() || null))) {
|
|
14
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function exactContextFile(base, query, rootIdentity) {
|
|
19
|
+
const parts = query.split('/');
|
|
20
|
+
if (!query || parts.some(part => !part || part === '.' || ignoredName(part))) return null;
|
|
21
|
+
let directory = base;
|
|
22
|
+
try {
|
|
23
|
+
for (let index = 0; index < parts.length; index++) {
|
|
24
|
+
assertBoundRoot(rootIdentity);
|
|
25
|
+
const target = path.join(directory, parts[index]), stat = await fs.lstat(target);
|
|
26
|
+
if (stat.isSymbolicLink() || (index < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())) return null;
|
|
27
|
+
const resolved = await fs.realpath(target), relative = path.relative(base, resolved);
|
|
28
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) ||
|
|
29
|
+
relative.split(path.sep).some(ignoredName)) return null;
|
|
30
|
+
// Recheck the named entry after resolving it and discard any observed
|
|
31
|
+
// link or entry replacement before offering the filename.
|
|
32
|
+
const confirmed = await fs.lstat(target);
|
|
33
|
+
if (confirmed.isSymbolicLink() || confirmed.dev !== stat.dev || confirmed.ino !== stat.ino) return null;
|
|
34
|
+
directory = resolved;
|
|
35
|
+
if (index === parts.length - 1) return relative.split(path.sep).join('/');
|
|
36
|
+
}
|
|
37
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** List bounded project-relative filenames only. Never open or upload their contents. */
|
|
42
|
+
export async function listContextFiles(root, query = '', { limit = 80, maxEntries = 10000, timeBudgetMs = 150, now = Date.now, rootIdentity = null } = {}) {
|
|
43
|
+
if (typeof query !== 'string' || query.length > 256 || /[\0\r\n]/.test(query) || path.isAbsolute(query) || query.includes('\\') || query.split('/').includes('..')) {
|
|
44
|
+
throw new CliError('INVALID_CONTEXT_QUERY', 'Use a project-relative filename query');
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || !Number.isFinite(timeBudgetMs) || timeBudgetMs < 0 || typeof now !== 'function') {
|
|
47
|
+
throw new TypeError('Invalid context file listing budget');
|
|
48
|
+
}
|
|
49
|
+
assertBoundRoot(rootIdentity);
|
|
50
|
+
const base = await fs.realpath(root), pending = [''], paths = [], needle = query.toLocaleLowerCase();
|
|
51
|
+
if (rootIdentity) assertBoundRoot(rootIdentity, await fs.lstat(base, { bigint: true }));
|
|
52
|
+
// An explicit filename remains usable even if an unrelated directory read
|
|
53
|
+
// consumes the scan budget. Keep the bounded substring scan for other hits.
|
|
54
|
+
const exact = await exactContextFile(base, query, rootIdentity);
|
|
55
|
+
if (exact) paths.push(exact);
|
|
56
|
+
let seen = 0, truncated = false;
|
|
57
|
+
const deadline = now() + timeBudgetMs;
|
|
58
|
+
outer: while (pending.length) {
|
|
59
|
+
if (now() >= deadline || seen >= maxEntries || paths.length >= limit) { truncated = true; break; }
|
|
60
|
+
assertBoundRoot(rootIdentity);
|
|
61
|
+
const relative = pending.shift(), directory = path.join(base, relative);
|
|
62
|
+
try {
|
|
63
|
+
if ((await fs.lstat(directory)).isSymbolicLink()) continue;
|
|
64
|
+
const resolved = await fs.realpath(directory), fromRoot = path.relative(base, resolved);
|
|
65
|
+
if (fromRoot === '..' || fromRoot.startsWith('..' + path.sep) || path.isAbsolute(fromRoot)) continue;
|
|
66
|
+
const handle = await fs.opendir(directory);
|
|
67
|
+
for await (const item of handle) {
|
|
68
|
+
seen++;
|
|
69
|
+
if (seen > maxEntries || now() >= deadline) { truncated = true; break outer; }
|
|
70
|
+
if (item.isSymbolicLink() || ignoredName(item.name)) continue;
|
|
71
|
+
const name = relative ? relative + '/' + item.name : item.name;
|
|
72
|
+
if (item.isDirectory()) pending.push(name);
|
|
73
|
+
else if (item.isFile() && name.toLocaleLowerCase().includes(needle) && name !== exact) {
|
|
74
|
+
paths.push(name);
|
|
75
|
+
if (paths.length >= limit) { truncated = true; break outer; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM'].includes(error.code)) throw error; }
|
|
79
|
+
}
|
|
80
|
+
assertBoundRoot(rootIdentity);
|
|
81
|
+
return { paths: paths.sort(), truncated };
|
|
82
|
+
}
|
package/lib/web/cookie-auth.mjs
CHANGED
|
@@ -4,23 +4,71 @@
|
|
|
4
4
|
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
|
|
7
|
+
// Cookies are shared across ports on one hostname, while the session Map is
|
|
8
|
+
// private to a runtime. Scope the name to the configured public endpoint so
|
|
9
|
+
// ordinary logins to another local runtime cannot replace this runtime's SID.
|
|
10
|
+
// Hostnames remain isolated by the browser's host-only cookie rules. This is
|
|
11
|
+
// coexistence isolation, not an authority boundary between same-host servers.
|
|
12
|
+
export function createCookieNameForRequest({ useTls = false, trustProxy = false, proxyOrigin = '' } = {}) {
|
|
13
|
+
let protocol = useTls ? 'https' : 'http';
|
|
14
|
+
let proxyPort;
|
|
15
|
+
if (trustProxy) {
|
|
16
|
+
let parsed;
|
|
17
|
+
try { parsed = new URL(proxyOrigin); } catch {}
|
|
18
|
+
if (!parsed || !['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password ||
|
|
19
|
+
parsed.pathname !== '/' || parsed.search || parsed.hash) {
|
|
20
|
+
throw new TypeError('Web cookie naming requires a valid configured proxy origin');
|
|
21
|
+
}
|
|
22
|
+
protocol = parsed.protocol.slice(0, -1);
|
|
23
|
+
proxyPort = Number(parsed.port || (protocol === 'https' ? 443 : 80));
|
|
24
|
+
if (!Number.isInteger(proxyPort) || proxyPort < 1 || proxyPort > 65535) {
|
|
25
|
+
throw new TypeError('Web cookie naming requires a valid configured proxy port');
|
|
26
|
+
}
|
|
27
|
+
} else if (proxyOrigin) {
|
|
28
|
+
throw new TypeError('Web cookie proxy origin requires trustProxy');
|
|
29
|
+
}
|
|
30
|
+
return (req) => {
|
|
31
|
+
// The socket reports the port actually bound (including --port 0 and
|
|
32
|
+
// auto-increment). Do not use Host or forwarded request headers here.
|
|
33
|
+
const port = proxyPort ?? req?.socket?.localPort;
|
|
34
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
35
|
+
throw new TypeError('Web cookie naming requires a valid listener port');
|
|
36
|
+
}
|
|
37
|
+
return `hcc_sid_v2_${protocol}_${port}`;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
7
41
|
export function createCookieAuth({
|
|
8
42
|
now,
|
|
9
43
|
ttlSec,
|
|
10
44
|
maxSessions,
|
|
45
|
+
cookieNameForRequest,
|
|
11
46
|
requestIsSecure,
|
|
12
47
|
trustProxy,
|
|
13
48
|
proxyOrigin,
|
|
14
49
|
authOk,
|
|
15
50
|
token
|
|
16
51
|
}) {
|
|
52
|
+
if (typeof cookieNameForRequest !== 'function') {
|
|
53
|
+
throw new TypeError('Web cookie authentication requires cookieNameForRequest');
|
|
54
|
+
}
|
|
17
55
|
const webSessions = new Map();
|
|
18
56
|
|
|
57
|
+
function cookieName(req) {
|
|
58
|
+
const name = cookieNameForRequest(req);
|
|
59
|
+
if (typeof name !== 'string' || !/^hcc_sid_v2_(?:http|https)_[1-9]\d{0,4}$/.test(name) ||
|
|
60
|
+
Number(name.slice(name.lastIndexOf('_') + 1)) > 65535) {
|
|
61
|
+
throw new TypeError('Web cookie authentication requires a scoped cookie name');
|
|
62
|
+
}
|
|
63
|
+
return name;
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
function parseCookieSid(req) {
|
|
67
|
+
const name = cookieName(req);
|
|
20
68
|
const header = req.headers.cookie || '';
|
|
21
69
|
for (const part of header.split(';')) {
|
|
22
70
|
const [k, ...rest] = part.trim().split('=');
|
|
23
|
-
if (k ===
|
|
71
|
+
if (k === name) {
|
|
24
72
|
const raw = rest.join('=');
|
|
25
73
|
if (raw.length > 128) return '';
|
|
26
74
|
try { return decodeURIComponent(raw); } catch { return ''; }
|
|
@@ -57,13 +105,13 @@ export function createCookieAuth({
|
|
|
57
105
|
}
|
|
58
106
|
|
|
59
107
|
function sessionCookieHeader(sid, req) {
|
|
60
|
-
const parts = [
|
|
108
|
+
const parts = [`${cookieName(req)}=${sid}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${ttlSec}`];
|
|
61
109
|
if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
|
|
62
110
|
return parts.join('; ');
|
|
63
111
|
}
|
|
64
112
|
|
|
65
113
|
function expiredSessionCookieHeader(req) {
|
|
66
|
-
const parts = [
|
|
114
|
+
const parts = [`${cookieName(req)}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
|
|
67
115
|
if (requestIsSecure(req, { trustProxy, proxyOrigin })) parts.push('Secure');
|
|
68
116
|
return parts.join('; ');
|
|
69
117
|
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import { ensurePrivateProjectBufferDirectory } from '../runtime/project-path.mjs';
|
|
8
10
|
import { withBufferDirectoryLease } from '../runtime/buffer-directory-lease.mjs';
|
|
9
11
|
import {
|
|
10
12
|
externalBufferEvidence,
|
|
@@ -13,19 +15,59 @@ import {
|
|
|
13
15
|
readExternalBufferMetadata
|
|
14
16
|
} from '../runtime/buffer-evidence.mjs';
|
|
15
17
|
import { inspectProcessIdentity } from '../process/identity.mjs';
|
|
18
|
+
import { unsafeDirectoryAcl } from '../runtime/project-trust.mjs';
|
|
19
|
+
|
|
20
|
+
function secureExistingBridgeFile(file) {
|
|
21
|
+
let stat;
|
|
22
|
+
try { stat = fs.lstatSync(file); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error?.code === 'ENOENT') return false;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 ||
|
|
28
|
+
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
|
29
|
+
(process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) ||
|
|
30
|
+
unsafeDirectoryAcl(file, stat)) {
|
|
31
|
+
throw new Error(`Unsafe external buffer file: ${file}`);
|
|
32
|
+
}
|
|
33
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
34
|
+
try {
|
|
35
|
+
const opened = fs.fstatSync(fd);
|
|
36
|
+
if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) {
|
|
37
|
+
throw new Error(`External buffer file changed: ${file}`);
|
|
38
|
+
}
|
|
39
|
+
if ((process.platform !== 'win32' && (opened.mode & 0o777) !== 0o600) ||
|
|
40
|
+
unsafeDirectoryAcl(file, opened)) {
|
|
41
|
+
throw new Error(`External buffer file lost private permissions: ${file}`);
|
|
42
|
+
}
|
|
43
|
+
const current = fs.lstatSync(file);
|
|
44
|
+
if (current.isSymbolicLink() || current.dev !== stat.dev || current.ino !== stat.ino) {
|
|
45
|
+
throw new Error(`External buffer file changed: ${file}`);
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
} finally { fs.closeSync(fd); }
|
|
49
|
+
}
|
|
16
50
|
|
|
17
51
|
export function createExternalSessions(deps) {
|
|
18
52
|
const {
|
|
19
|
-
ctx, sessions,
|
|
53
|
+
ctx, sessions, isStopping = () => false,
|
|
20
54
|
sessionKey, broadcast,
|
|
21
55
|
now, tx, connectWebProject,
|
|
22
|
-
|
|
56
|
+
runtimeProjectContexts,
|
|
23
57
|
refreshPeerIoHeartbeat, redactedLogText, BUFS_DIR_NAME
|
|
24
58
|
} = deps;
|
|
25
59
|
|
|
26
60
|
// ── Optional external buffer-file session adoption ───────────────────────
|
|
27
|
-
const bufsDir =
|
|
28
|
-
|
|
61
|
+
const bufsDir = ensurePrivateProjectBufferDirectory(ctx.root, BUFS_DIR_NAME);
|
|
62
|
+
let stopped = false;
|
|
63
|
+
const pendingAdoptions = new Set();
|
|
64
|
+
const discoveryStopped = () => stopped || isStopping();
|
|
65
|
+
|
|
66
|
+
function stopExternalDiscovery() {
|
|
67
|
+
stopped = true;
|
|
68
|
+
for (const timer of pendingAdoptions) clearTimeout(timer);
|
|
69
|
+
pendingAdoptions.clear();
|
|
70
|
+
}
|
|
29
71
|
|
|
30
72
|
function removeExternalBufferFiles(id, directory = bufsDir) {
|
|
31
73
|
for (const suffix of ['out', 'in', 'resize', 'meta']) {
|
|
@@ -34,6 +76,16 @@ export function createExternalSessions(deps) {
|
|
|
34
76
|
}
|
|
35
77
|
|
|
36
78
|
function adoptExternalSession(id, pctx = ctx, directory = bufsDir) {
|
|
79
|
+
if (discoveryStopped()) return;
|
|
80
|
+
let privateDirectory;
|
|
81
|
+
let suppliedDirectory;
|
|
82
|
+
try {
|
|
83
|
+
privateDirectory = ensurePrivateProjectBufferDirectory(pctx.root, BUFS_DIR_NAME, { create: false });
|
|
84
|
+
suppliedDirectory = fs.realpathSync.native(directory);
|
|
85
|
+
}
|
|
86
|
+
catch { return; }
|
|
87
|
+
if (!privateDirectory || suppliedDirectory !== privateDirectory) return;
|
|
88
|
+
directory = privateDirectory;
|
|
37
89
|
const key = sessionKey(pctx, id);
|
|
38
90
|
if (sessions.has(key)) return;
|
|
39
91
|
const outFile = path.join(directory, `${id}.out`);
|
|
@@ -44,6 +96,9 @@ export function createExternalSessions(deps) {
|
|
|
44
96
|
try {
|
|
45
97
|
adopted = withBufferDirectoryLease(directory, () => {
|
|
46
98
|
if (!fs.existsSync(outFile) || !fs.existsSync(metaFile)) return null;
|
|
99
|
+
for (const file of [outFile, inFile, resizeFile, metaFile]) {
|
|
100
|
+
secureExistingBridgeFile(file);
|
|
101
|
+
}
|
|
47
102
|
let meta;
|
|
48
103
|
try { meta = readExternalBufferMetadata(metaFile); } catch { return null; }
|
|
49
104
|
// The producer publishes wrapper evidence before the PTY child identity
|
|
@@ -116,6 +171,7 @@ export function createExternalSessions(deps) {
|
|
|
116
171
|
outputOffset = fs.fstatSync(session.outputFd).size;
|
|
117
172
|
} catch {}
|
|
118
173
|
session.outputPoller = setInterval(() => {
|
|
174
|
+
if (discoveryStopped()) return;
|
|
119
175
|
try {
|
|
120
176
|
if (session.outputFd === null) return;
|
|
121
177
|
const stat = fs.fstatSync(session.outputFd);
|
|
@@ -175,6 +231,7 @@ export function createExternalSessions(deps) {
|
|
|
175
231
|
// producer. Owner changes detach only this stale in-memory view; they never
|
|
176
232
|
// mark the replacement producer exited or remove its files.
|
|
177
233
|
session.exitPoller = setInterval(() => {
|
|
234
|
+
if (discoveryStopped()) return;
|
|
178
235
|
try {
|
|
179
236
|
withBufferDirectoryLease(directory, () => {
|
|
180
237
|
const outExists = fs.existsSync(outFile);
|
|
@@ -210,8 +267,12 @@ export function createExternalSessions(deps) {
|
|
|
210
267
|
|
|
211
268
|
// Adopt any already-running external sessions
|
|
212
269
|
function scanExternalSessions() {
|
|
270
|
+
if (discoveryStopped()) return;
|
|
213
271
|
for (const projectCtx of runtimeProjectContexts()) {
|
|
214
|
-
|
|
272
|
+
let directory;
|
|
273
|
+
try { directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false }); }
|
|
274
|
+
catch { continue; }
|
|
275
|
+
if (!directory) continue;
|
|
215
276
|
for (const id of externalBufferSessionIds(directory)) {
|
|
216
277
|
adoptExternalSession(id, projectCtx, directory);
|
|
217
278
|
}
|
|
@@ -227,14 +288,20 @@ export function createExternalSessions(deps) {
|
|
|
227
288
|
// scanExternalSessions get their own watcher on the next scan tick.
|
|
228
289
|
const bufsWatchers = new Map();
|
|
229
290
|
function ensureBufsWatchers() {
|
|
291
|
+
if (discoveryStopped()) return;
|
|
230
292
|
for (const projectCtx of runtimeProjectContexts()) {
|
|
231
293
|
const root = path.resolve(projectCtx.root);
|
|
232
294
|
if (bufsWatchers.has(root)) continue;
|
|
233
|
-
const directory = bufferDirectory(projectCtx);
|
|
234
295
|
try {
|
|
296
|
+
const directory = ensurePrivateProjectBufferDirectory(projectCtx.root, BUFS_DIR_NAME, { create: false });
|
|
297
|
+
if (!directory) continue;
|
|
235
298
|
const watcher = fs.watch(directory, { persistent: false }, (event, filename) => {
|
|
236
|
-
if (filename?.endsWith('.out')) {
|
|
237
|
-
setTimeout(() =>
|
|
299
|
+
if (!discoveryStopped() && filename?.endsWith('.out')) {
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
pendingAdoptions.delete(timer);
|
|
302
|
+
adoptExternalSession(path.basename(filename, '.out'), projectCtx, directory);
|
|
303
|
+
}, 300);
|
|
304
|
+
pendingAdoptions.add(timer);
|
|
238
305
|
}
|
|
239
306
|
});
|
|
240
307
|
watcher.on('error', (err) => {
|
|
@@ -249,6 +316,6 @@ export function createExternalSessions(deps) {
|
|
|
249
316
|
|
|
250
317
|
return {
|
|
251
318
|
bufsWatchers, externalScanPoller, bufsWatcherSyncPoller,
|
|
252
|
-
scanExternalSessions, adoptExternalSession, removeExternalBufferFiles
|
|
319
|
+
scanExternalSessions, adoptExternalSession, removeExternalBufferFiles, stopExternalDiscovery
|
|
253
320
|
};
|
|
254
321
|
}
|