@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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { TextDecoder } from 'node:util';
|
|
6
|
+
import { CliError } from '../shared/errors.mjs';
|
|
7
|
+
|
|
8
|
+
export const PROJECT_FILE_LIMITS = Object.freeze({ entries: 500, scannedEntries: 5000,
|
|
9
|
+
textBytes: 1024 * 1024, mediaBytes: 10 * 1024 * 1024, sniffBytes: 4096 });
|
|
10
|
+
|
|
11
|
+
const ignored = new Set(['.git', '.hg', '.svn', '.hello-cc', '.hcc', '.codex', '.claude', '.dsh',
|
|
12
|
+
'.ssh', '.aws', '.azure', '.gcloud', '.gnupg', '.config', '.local', '.docker', '.kube', '.terraform',
|
|
13
|
+
'node_modules', '.cache', '.npm', '.pnpm-store', '.yarn', '.next', '.nuxt', '.turbo', '.parcel-cache',
|
|
14
|
+
'coverage', '__pycache__', '.venv', 'venv', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.tox']);
|
|
15
|
+
const privateNames = new Set(['.envrc', '.npmrc', '.pypirc', '.netrc', '.git-credentials', '.gitconfig',
|
|
16
|
+
'.boto', '.bash_history', '.zsh_history', '.python_history', 'auth.json', 'accounts.json']);
|
|
17
|
+
const textExtensions = new Set(['.txt', '.log', '.json', '.jsonl', '.yaml', '.yml', '.toml', '.ini', '.cfg',
|
|
18
|
+
'.conf', '.xml', '.csv', '.tsv', '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.css', '.scss', '.sass',
|
|
19
|
+
'.less', '.py', '.rb', '.go', '.rs', '.c', '.h', '.cc', '.cpp', '.hpp', '.java', '.kt', '.swift', '.sh',
|
|
20
|
+
'.bash', '.zsh', '.fish', '.ps1', '.sql', '.graphql', '.gql', '.vue', '.svelte', '.mdx', '.svg', '.tex',
|
|
21
|
+
'.rst', '.diff', '.patch', '.lock', '.properties', '.gradle', '.cmake']);
|
|
22
|
+
const markdownExtensions = new Set(['.md', '.markdown', '.mdown']);
|
|
23
|
+
const htmlExtensions = new Set(['.html', '.htm']);
|
|
24
|
+
const media = new Map([['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'],
|
|
25
|
+
['.gif', 'image/gif'], ['.webp', 'image/webp'], ['.pdf', 'application/pdf']]);
|
|
26
|
+
const errorStatus = new Map([['PROJECT_FILE_BAD_PATH', 400], ['PROJECT_FILE_FORBIDDEN', 403],
|
|
27
|
+
['PROJECT_FILE_NOT_FOUND', 404], ['PROJECT_FILE_CHANGED', 409], ['PROJECT_FILE_TOO_LARGE', 413],
|
|
28
|
+
['PROJECT_FILE_CONFLICT', 409], ['PROJECT_FILE_EXISTS', 409],
|
|
29
|
+
['PROJECT_FILE_WRITE_UNCONFIRMED', 503], ['PROJECT_FILE_WRITE_UNAVAILABLE', 503],
|
|
30
|
+
['PROJECT_FILE_NOT_DIRECTORY', 400], ['PROJECT_FILE_NOT_REGULAR', 400], ['PROJECT_FILE_UNAVAILABLE', 500]]);
|
|
31
|
+
|
|
32
|
+
export function projectFileErrorStatus(error) { return errorStatus.get(error?.code); }
|
|
33
|
+
|
|
34
|
+
function privateComponent(name) {
|
|
35
|
+
const lower = name.toLowerCase();
|
|
36
|
+
return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[.-]|$)/.test(lower) ||
|
|
37
|
+
/\.env(?:[.-]|$)/.test(lower) ||
|
|
38
|
+
/(?:^|[._-])(?:secrets?|credentials?|tokens?)(?:[._-]|$)/.test(lower) ||
|
|
39
|
+
/^(?:id_(?:rsa|dsa|ecdsa|ed25519)|terraform\.tfstate)(?:[.-]|$)/.test(lower) ||
|
|
40
|
+
/^service[-_]account(?:[._-]|$)/.test(lower) || /\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ovpn)(?:[.-]|$)/.test(lower);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function relativePath(value, allowRoot) {
|
|
44
|
+
if (typeof value !== 'string' || value.length > 4096 || /[\\:\x00-\x1f\x7f]/.test(value) ||
|
|
45
|
+
path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) {
|
|
46
|
+
throw new CliError('PROJECT_FILE_BAD_PATH', 'Use a project-relative path with forward slashes');
|
|
47
|
+
}
|
|
48
|
+
if (allowRoot && value === '') return [];
|
|
49
|
+
const parts = value.split('/');
|
|
50
|
+
if (parts.length > 128 || parts.some(part => !part || part === '.' || part === '..')) {
|
|
51
|
+
throw new CliError('PROJECT_FILE_BAD_PATH', 'Use a project-relative path without traversal or empty components');
|
|
52
|
+
}
|
|
53
|
+
if (parts.some(privateComponent)) throw new CliError('PROJECT_FILE_FORBIDDEN', 'This private or cache path is excluded from project previews');
|
|
54
|
+
return parts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function projectFilePathParts(value, allowRoot) { return relativePath(value, allowRoot); }
|
|
58
|
+
|
|
59
|
+
export function isProjectTextPath(name) {
|
|
60
|
+
const extension = path.extname(name).toLowerCase();
|
|
61
|
+
return !extension || textExtensions.has(extension) || markdownExtensions.has(extension) || htmlExtensions.has(extension);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function projectContentRevision(stat, bytes) {
|
|
65
|
+
const identity = [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs, stat.birthtimeNs, stat.nlink]
|
|
66
|
+
.map(value => value === null || value === undefined ? null : String(value));
|
|
67
|
+
return 'v1-' + createHash('sha256').update(JSON.stringify(identity)).update('\0').update(bytes).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function contentHash(bytes) { return createHash('sha256').update(bytes).digest('hex'); }
|
|
71
|
+
|
|
72
|
+
function sameIdentity(a, b) {
|
|
73
|
+
return a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.size === b.size &&
|
|
74
|
+
a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function changed() { return new CliError('PROJECT_FILE_CHANGED', 'The project path changed while being read; refresh and try again'); }
|
|
78
|
+
|
|
79
|
+
function assertBoundRoot(rootIdentity, stat = null) {
|
|
80
|
+
if (!rootIdentity) return;
|
|
81
|
+
rootIdentity.assertUnchanged();
|
|
82
|
+
if (stat && (rootIdentity.identity.dev !== stat.dev.toString() ||
|
|
83
|
+
rootIdentity.identity.ino !== stat.ino.toString() ||
|
|
84
|
+
rootIdentity.identity.birthtimeNs !== (stat.birthtimeNs?.toString() || null))) {
|
|
85
|
+
throw new CliError('PROJECT_PATH_CHANGED', 'Selected project directory changed; select its current location again');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function publicError(error) {
|
|
90
|
+
if (error instanceof CliError) return error;
|
|
91
|
+
if (['ENOENT', 'ENOTDIR'].includes(error.code)) return new CliError('PROJECT_FILE_NOT_FOUND', 'The requested project path was not found');
|
|
92
|
+
if (['EACCES', 'EPERM', 'ELOOP'].includes(error.code)) return new CliError('PROJECT_FILE_FORBIDDEN', 'The requested project path cannot be previewed');
|
|
93
|
+
return new CliError('PROJECT_FILE_UNAVAILABLE', 'The requested project path could not be read');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function inspectPath(root, parts, directory, rootIdentity) {
|
|
97
|
+
assertBoundRoot(rootIdentity);
|
|
98
|
+
const base = await fs.realpath(root), chain = [];
|
|
99
|
+
let absolute = base;
|
|
100
|
+
for (let index = 0; index <= parts.length; index++) {
|
|
101
|
+
if (index) absolute = path.join(absolute, parts[index - 1]);
|
|
102
|
+
const stat = await fs.lstat(absolute, { bigint: true });
|
|
103
|
+
if (!index) assertBoundRoot(rootIdentity, stat);
|
|
104
|
+
if (stat.isSymbolicLink()) throw new CliError('PROJECT_FILE_FORBIDDEN', 'Symbolic links are excluded from project previews');
|
|
105
|
+
const needsDirectory = index < parts.length || directory;
|
|
106
|
+
if (needsDirectory && !stat.isDirectory()) throw new CliError('PROJECT_FILE_NOT_DIRECTORY', 'The requested project path is not a directory');
|
|
107
|
+
if (!needsDirectory && (!stat.isFile() || stat.nlink !== 1n)) {
|
|
108
|
+
throw new CliError('PROJECT_FILE_FORBIDDEN', 'Only ordinary files with one link can be previewed');
|
|
109
|
+
}
|
|
110
|
+
chain.push({ absolute, stat });
|
|
111
|
+
}
|
|
112
|
+
if (await fs.realpath(absolute) !== absolute) throw changed();
|
|
113
|
+
return { root, base, absolute, chain, stat: chain.at(-1).stat, rootIdentity };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function verifyPath(snapshot, handle) {
|
|
117
|
+
assertBoundRoot(snapshot.rootIdentity, snapshot.chain[0].stat);
|
|
118
|
+
if (await fs.realpath(snapshot.root) !== snapshot.base || await fs.realpath(snapshot.absolute) !== snapshot.absolute) throw changed();
|
|
119
|
+
for (const item of snapshot.chain) {
|
|
120
|
+
const current = await fs.lstat(item.absolute, { bigint: true });
|
|
121
|
+
if (current.isSymbolicLink() || !sameIdentity(current, item.stat)) throw changed();
|
|
122
|
+
}
|
|
123
|
+
if (!sameIdentity(await handle.stat({ bigint: true }), snapshot.stat)) throw changed();
|
|
124
|
+
assertBoundRoot(snapshot.rootIdentity, snapshot.chain[0].stat);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function verifyProjectFileParent(snapshot, handle) { await verifyPath(snapshot, handle); }
|
|
128
|
+
|
|
129
|
+
/** Caller owns the returned parent FD and must close it after its operation. */
|
|
130
|
+
export async function prepareProjectFileParent(root, relative, { rootIdentity } = {}) {
|
|
131
|
+
try {
|
|
132
|
+
const parts = relativePath(relative, false);
|
|
133
|
+
const snapshot = await inspectPath(root, parts.slice(0, -1), true, rootIdentity);
|
|
134
|
+
const handle = await openChecked(snapshot, true);
|
|
135
|
+
return { snapshot, handle, name: parts.at(-1) };
|
|
136
|
+
} catch (error) { throw publicError(error); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function openChecked(snapshot, directory) {
|
|
140
|
+
const handle = await fs.open(snapshot.absolute, constants.O_RDONLY | constants.O_NOFOLLOW |
|
|
141
|
+
constants.O_NONBLOCK | (directory ? constants.O_DIRECTORY : 0));
|
|
142
|
+
try {
|
|
143
|
+
const stat = await handle.stat({ bigint: true });
|
|
144
|
+
if (!sameIdentity(stat, snapshot.stat) || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)) throw changed();
|
|
145
|
+
await verifyPath(snapshot, handle);
|
|
146
|
+
return handle;
|
|
147
|
+
} catch (error) { await handle.close(); throw error; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Single-level listing only. No project module or executable is loaded. */
|
|
151
|
+
export async function listProjectFiles(root, relative = '', { rootIdentity } = {}) {
|
|
152
|
+
let handle, directory;
|
|
153
|
+
try {
|
|
154
|
+
const parts = relativePath(relative, true), snapshot = await inspectPath(root, parts, true, rootIdentity);
|
|
155
|
+
handle = await openChecked(snapshot, true);
|
|
156
|
+
directory = await fs.opendir(snapshot.absolute);
|
|
157
|
+
const entries = [];
|
|
158
|
+
let scanned = 0, truncated = false;
|
|
159
|
+
for await (const item of directory) {
|
|
160
|
+
if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
|
|
161
|
+
if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
|
|
162
|
+
if (!item.isDirectory() && !item.isFile()) continue;
|
|
163
|
+
let stat;
|
|
164
|
+
try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
|
|
165
|
+
catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
|
|
166
|
+
if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
|
|
167
|
+
const entry = { name: item.name, path: relative ? relative + '/' + item.name : item.name,
|
|
168
|
+
type: stat.isDirectory() ? 'directory' : 'file' };
|
|
169
|
+
if (stat.isFile()) entry.size = Number(stat.size);
|
|
170
|
+
entries.push(entry);
|
|
171
|
+
}
|
|
172
|
+
directory = null; // for-await closes the handle on completion and break.
|
|
173
|
+
await verifyPath(snapshot, handle);
|
|
174
|
+
assertBoundRoot(rootIdentity, snapshot.chain[0].stat);
|
|
175
|
+
entries.sort((a, b) => a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.type === 'directory' ? -1 : 1);
|
|
176
|
+
if (entries.length > PROJECT_FILE_LIMITS.entries) truncated = true;
|
|
177
|
+
return { path: relative, entries: entries.slice(0, PROJECT_FILE_LIMITS.entries), truncated };
|
|
178
|
+
} catch (error) { throw publicError(error); }
|
|
179
|
+
finally { await directory?.close().catch(() => {}); await handle?.close().catch(() => {}); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readBytes(handle, length) {
|
|
183
|
+
const buffer = Buffer.alloc(length);
|
|
184
|
+
let offset = 0;
|
|
185
|
+
while (offset < length) {
|
|
186
|
+
const { bytesRead } = await handle.read(buffer, offset, length - offset, offset);
|
|
187
|
+
if (!bytesRead) throw changed();
|
|
188
|
+
offset += bytesRead;
|
|
189
|
+
}
|
|
190
|
+
return buffer;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function textContent(bytes, truncated) {
|
|
194
|
+
try {
|
|
195
|
+
// Streaming decode deliberately withholds an incomplete final UTF-8 code
|
|
196
|
+
// point when the file exceeds the byte budget.
|
|
197
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes, { stream: truncated });
|
|
198
|
+
if (/[\x00-\x08\x0b\x0e-\x1f\x7f]/.test(text)) return null;
|
|
199
|
+
return text;
|
|
200
|
+
} catch { return null; }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function projectTextContent(bytes, truncated) { return textContent(bytes, truncated); }
|
|
204
|
+
|
|
205
|
+
function newlineStyle(text) {
|
|
206
|
+
if (!/[\r\n]/.test(text)) return 'none';
|
|
207
|
+
const withoutCrlf = text.replace(/\r\n/g, '');
|
|
208
|
+
const hasBareNewline = /[\r\n]/.test(withoutCrlf);
|
|
209
|
+
if (text.includes('\r\n')) return hasBareNewline ? 'mixed' : 'crlf';
|
|
210
|
+
return text.includes('\r') ? 'mixed' : 'lf';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function validMedia(bytes, mime) {
|
|
214
|
+
if (mime === 'image/png') return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
|
215
|
+
if (mime === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
216
|
+
if (mime === 'image/gif') return ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'));
|
|
217
|
+
if (mime === 'image/webp') return bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
|
218
|
+
return mime === 'application/pdf' && /^%PDF-[12]\.\d/.test(bytes.subarray(0, 8).toString('ascii'));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Content is returned only as JSON data; callers must isolate HTML/media. */
|
|
222
|
+
export async function previewProjectFile(root, relative, { rootIdentity } = {}) {
|
|
223
|
+
let handle;
|
|
224
|
+
try {
|
|
225
|
+
const parts = relativePath(relative, false), snapshot = await inspectPath(root, parts, false, rootIdentity);
|
|
226
|
+
handle = await openChecked(snapshot, false);
|
|
227
|
+
const name = parts.at(-1), size = Number(snapshot.stat.size), extension = path.extname(name).toLowerCase();
|
|
228
|
+
const result = { path: relative, name, size, kind: 'unsupported', mime: 'application/octet-stream', encoding: 'base64', content: '', editable: false };
|
|
229
|
+
const mime = media.get(extension);
|
|
230
|
+
if (mime) {
|
|
231
|
+
if (size > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'Image and PDF previews are limited to 10 MiB');
|
|
232
|
+
const bytes = await readBytes(handle, size);
|
|
233
|
+
if (validMedia(bytes, mime)) Object.assign(result, { kind: mime === 'application/pdf' ? 'pdf' : 'image', mime, content: bytes.toString('base64') });
|
|
234
|
+
} else if (isProjectTextPath(name)) {
|
|
235
|
+
const prefix = await readBytes(handle, Math.min(size, PROJECT_FILE_LIMITS.sniffBytes));
|
|
236
|
+
if (textContent(prefix, size > prefix.length) !== null) {
|
|
237
|
+
const bytes = size <= prefix.length ? prefix : await readBytes(handle, Math.min(size, PROJECT_FILE_LIMITS.textBytes));
|
|
238
|
+
const truncated = size > bytes.length, content = textContent(bytes, truncated);
|
|
239
|
+
if (content !== null) {
|
|
240
|
+
Object.assign(result, { kind: markdownExtensions.has(extension) ? 'markdown' : htmlExtensions.has(extension) ? 'html' : 'text',
|
|
241
|
+
mime: markdownExtensions.has(extension) ? 'text/markdown' : htmlExtensions.has(extension) ? 'text/html' : 'text/plain',
|
|
242
|
+
encoding: 'utf8', content, truncated });
|
|
243
|
+
if (!truncated) Object.assign(result, { editable: true, revision: projectContentRevision(snapshot.stat, bytes),
|
|
244
|
+
contentHash: contentHash(bytes), bom: content.startsWith('\ufeff'), newline: newlineStyle(content) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
await verifyPath(snapshot, handle);
|
|
249
|
+
assertBoundRoot(rootIdentity, snapshot.chain[0].stat);
|
|
250
|
+
return result;
|
|
251
|
+
} catch (error) { throw publicError(error); }
|
|
252
|
+
finally { await handle?.close().catch(() => {}); }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A bounded exact-byte status response; it never returns file content. */
|
|
256
|
+
export async function inspectProjectFileStatus(root, relative, { rootIdentity } = {}) {
|
|
257
|
+
let handle;
|
|
258
|
+
try {
|
|
259
|
+
const parts = relativePath(relative, false), snapshot = await inspectPath(root, parts, false, rootIdentity);
|
|
260
|
+
handle = await openChecked(snapshot, false);
|
|
261
|
+
const size = Number(snapshot.stat.size);
|
|
262
|
+
if (size > PROJECT_FILE_LIMITS.mediaBytes) throw new CliError('PROJECT_FILE_TOO_LARGE', 'File status reads are limited to 10 MiB');
|
|
263
|
+
const bytes = await readBytes(handle, size);
|
|
264
|
+
await verifyPath(snapshot, handle);
|
|
265
|
+
return { path: relative, size, contentHash: contentHash(bytes) };
|
|
266
|
+
} catch (error) { throw publicError(error); }
|
|
267
|
+
finally { await handle?.close().catch(() => {}); }
|
|
268
|
+
}
|
package/lib/web/pty-sessions.mjs
CHANGED
|
@@ -15,10 +15,12 @@ import { providerSessionPeerId } from '../core/peers/session.mjs';
|
|
|
15
15
|
import { childSessionEnv } from '../core/sessions/launch.mjs';
|
|
16
16
|
import { detectBranch } from '../project-context.mjs';
|
|
17
17
|
import { tx } from '../db/schema.mjs';
|
|
18
|
+
import { spawnPinnedCwdPty } from '../process/pinned-cwd.mjs';
|
|
19
|
+
import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
|
|
18
20
|
|
|
19
21
|
export function createPtySessions(deps) {
|
|
20
22
|
const {
|
|
21
|
-
pty, ctx, sessions,
|
|
23
|
+
pty, ctx, sessions, isStopping = () => false,
|
|
22
24
|
broadcast, closeSessionClients, sessionKey,
|
|
23
25
|
connectWebProject, nextProjectSessionId,
|
|
24
26
|
startTmuxManagedSession,
|
|
@@ -40,6 +42,7 @@ export function createPtySessions(deps) {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
function startPtySession(input) {
|
|
45
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
43
46
|
const pctx = input.projectCtx || ctx;
|
|
44
47
|
const kind = input.kind || 'shell';
|
|
45
48
|
const id = input.id || nextProjectSessionId(pctx, kind);
|
|
@@ -54,6 +57,9 @@ export function createPtySessions(deps) {
|
|
|
54
57
|
const role = input.role || 'peer';
|
|
55
58
|
const command = input.command || defaultSessionCommand(kind);
|
|
56
59
|
const cwd = path.resolve(input.cwd || pctx.root);
|
|
60
|
+
const expectedCwdIdentity = input.cwdIdentity ||
|
|
61
|
+
(cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
|
|
62
|
+
captureSelectedCwdSnapshot(cwd);
|
|
57
63
|
const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
|
|
58
64
|
const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
|
|
59
65
|
const env = childSessionEnv({
|
|
@@ -61,14 +67,15 @@ export function createPtySessions(deps) {
|
|
|
61
67
|
HCC_ROOT: pctx.root,
|
|
62
68
|
HCC_DB: pctx.dbPath,
|
|
63
69
|
TERM: 'xterm-256color'
|
|
64
|
-
}, callerEnv);
|
|
70
|
+
}, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
|
|
65
71
|
const size = input.size || { cols: 100, rows: 30 };
|
|
66
72
|
const previousSession = sessions.get(key);
|
|
67
|
-
const child = pty
|
|
73
|
+
const child = spawnPinnedCwdPty(pty, shell, ['-c', command], {
|
|
68
74
|
name: 'xterm-256color',
|
|
69
75
|
cols: size.cols,
|
|
70
76
|
rows: size.rows,
|
|
71
77
|
cwd,
|
|
78
|
+
expectedIdentity: expectedCwdIdentity,
|
|
72
79
|
env
|
|
73
80
|
});
|
|
74
81
|
const session = {
|
|
@@ -218,6 +225,7 @@ export function createPtySessions(deps) {
|
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
function startSession(input) {
|
|
228
|
+
if (isStopping()) throw new CliError('RUNTIME_STOPPING', 'Web runtime is stopping');
|
|
221
229
|
const normalized = normalizeWebSessionInput(input);
|
|
222
230
|
if (normalized.backend === 'pty') return startPtySession(normalized);
|
|
223
231
|
return startTmuxManagedSession(normalized);
|