@sovovs/bycli 2.1.1 → 2.1.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/dist/src/daemon-config.d.ts +2 -0
- package/dist/src/daemon-config.js +11 -0
- package/dist/src/daemon-config.test.d.ts +1 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +14 -3
- package/dist/src/recorder/highlevel/init.d.ts +7 -0
- package/dist/src/recorder/highlevel/init.js +134 -6
- package/dist/src/recorder/http/handlers.js +27 -1
- package/dist/src/recorder/runner/verify-runner-main.js +1 -1
- package/dist/src/release-workflow.test.d.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ConfigError } from './errors.js';
|
|
2
|
+
export function resolveDaemonHost(env = process.env) {
|
|
3
|
+
const raw = env.BYCLI_DAEMON_HOST;
|
|
4
|
+
if (raw === undefined || raw === '') {
|
|
5
|
+
return '127.0.0.1';
|
|
6
|
+
}
|
|
7
|
+
if (raw === '127.0.0.1' || raw === '0.0.0.0') {
|
|
8
|
+
return raw;
|
|
9
|
+
}
|
|
10
|
+
throw new ConfigError(`config_invalid: BYCLI_DAEMON_HOST=${raw} is not allowed`, 'Use 127.0.0.1 for local use or 0.0.0.0 for an isolated sandbox.');
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/daemon.d.ts
CHANGED
|
@@ -17,6 +17,6 @@
|
|
|
17
17
|
* Lifecycle:
|
|
18
18
|
* - Auto-spawned by bycli on first browser command
|
|
19
19
|
* - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall
|
|
20
|
-
* - Listens on
|
|
20
|
+
* - Listens on 127.0.0.1:19825 by default; isolated sandboxes may opt into 0.0.0.0
|
|
21
21
|
*/
|
|
22
22
|
export {};
|
package/dist/src/daemon.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Lifecycle:
|
|
18
18
|
* - Auto-spawned by bycli on first browser command
|
|
19
19
|
* - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall
|
|
20
|
-
* - Listens on
|
|
20
|
+
* - Listens on 127.0.0.1:19825 by default; isolated sandboxes may opt into 0.0.0.0
|
|
21
21
|
*/
|
|
22
22
|
import { createServer } from 'node:http';
|
|
23
23
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
@@ -37,7 +37,9 @@ import { defaultSessionKeyRegistry } from './recorder/runner/session-keys.js';
|
|
|
37
37
|
import { recordExtensionVersion } from './update-check.js';
|
|
38
38
|
import { EXTENSION_CAPABILITY_MISSING_ERROR_CODE, EXTENSION_CAPABILITY_MISSING_HTTP_STATUS, extensionCapabilityHint, missingRequiredExtensionCapability, normalizeExtensionCapabilities, } from './browser/extension-capabilities.js';
|
|
39
39
|
import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
|
|
40
|
+
import { resolveDaemonHost } from './daemon-config.js';
|
|
40
41
|
const PORT = parseInt(process.env.BYCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
42
|
+
const HOST = resolveDaemonHost();
|
|
41
43
|
// The verify runner (M6b) spawns child processes that connect back to THIS daemon for a
|
|
42
44
|
// browser Page. Hand them our port (→ BYCLI_DAEMON_PORT in the child env) so the child's
|
|
43
45
|
// Page reaches us, not a freshly-spawned daemon. Must run before the first /v1/verify
|
|
@@ -286,6 +288,15 @@ async function handleRequest(req, res) {
|
|
|
286
288
|
}
|
|
287
289
|
const result = saveAdapterSource(body);
|
|
288
290
|
if (!result.ok) {
|
|
291
|
+
if (result.errorCode === 'adapter_exists') {
|
|
292
|
+
jsonResponse(res, 409, {
|
|
293
|
+
ok: false,
|
|
294
|
+
errorCode: result.errorCode,
|
|
295
|
+
error: result.reason,
|
|
296
|
+
data: { adapterPath: result.adapterPath },
|
|
297
|
+
});
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
289
300
|
jsonResponse(res, 400, { ok: false, errorCode: result.errorCode, error: result.reason });
|
|
290
301
|
return;
|
|
291
302
|
}
|
|
@@ -585,8 +596,8 @@ wss.on('connection', (ws) => {
|
|
|
585
596
|
});
|
|
586
597
|
});
|
|
587
598
|
// ─── Start ───────────────────────────────────────────────────────────
|
|
588
|
-
httpServer.listen(PORT,
|
|
589
|
-
log.info(`[daemon] Listening on http
|
|
599
|
+
httpServer.listen(PORT, HOST, () => {
|
|
600
|
+
log.info(`[daemon] Listening on http://${HOST}:${PORT}`);
|
|
590
601
|
// Temp-store reap policy (M7b · 09:27-29). Resolved once at startup; out-of-range env → throws,
|
|
591
602
|
// but we keep the daemon alive by falling back to the (validated-elsewhere) defaults on error.
|
|
592
603
|
let tempPolicy;
|
|
@@ -93,6 +93,8 @@ export interface SaveAdapterInput {
|
|
|
93
93
|
source: string;
|
|
94
94
|
/** 生成所用模型(标进 provenance)。 */
|
|
95
95
|
llmModel?: string;
|
|
96
|
+
/** 是否允许覆盖已有 adapter;默认 false。 */
|
|
97
|
+
overwrite?: boolean;
|
|
96
98
|
}
|
|
97
99
|
export type SaveAdapterResult = {
|
|
98
100
|
ok: true;
|
|
@@ -102,6 +104,11 @@ export type SaveAdapterResult = {
|
|
|
102
104
|
ok: false;
|
|
103
105
|
errorCode: 'validation_failed';
|
|
104
106
|
reason: string;
|
|
107
|
+
} | {
|
|
108
|
+
ok: false;
|
|
109
|
+
errorCode: 'adapter_exists';
|
|
110
|
+
reason: string;
|
|
111
|
+
adapterPath: string;
|
|
105
112
|
};
|
|
106
113
|
export declare function saveAdapterSource(input: SaveAdapterInput): SaveAdapterResult;
|
|
107
114
|
export interface InitRecoveryResult {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from 'node:fs';
|
|
17
17
|
import * as path from 'node:path';
|
|
18
18
|
import { getUserClisDir, getSitesDir, getSiteRecorderDir } from '../../config-paths.js';
|
|
19
|
+
import { getCliManifestPath } from '../../package-paths.js';
|
|
19
20
|
import { createHash, randomUUID } from 'node:crypto';
|
|
20
21
|
import { validateAdapterName, renderAdapterTemplate, buildProvenanceHeader, computeDryRunDiff, decideInitRecovery, } from '@sovovs/bycli-recorder-core';
|
|
21
22
|
const DEFAULT_SNAPSHOT = {
|
|
@@ -47,16 +48,98 @@ function atomicWrite(finalPath, content) {
|
|
|
47
48
|
const dir = path.dirname(finalPath);
|
|
48
49
|
fs.mkdirSync(dir, { recursive: true });
|
|
49
50
|
const tmp = path.join(dir, `.${path.basename(finalPath)}.${randomUUID()}.tmp`);
|
|
50
|
-
|
|
51
|
+
let fd;
|
|
51
52
|
try {
|
|
52
|
-
fs.
|
|
53
|
+
fd = fs.openSync(tmp, 'wx', 0o600); // exclusive create
|
|
54
|
+
fs.writeFileSync(fd, content);
|
|
53
55
|
fs.fsyncSync(fd);
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
fd = undefined;
|
|
58
|
+
fs.renameSync(tmp, finalPath);
|
|
59
|
+
fsyncDir(dir);
|
|
54
60
|
}
|
|
55
61
|
finally {
|
|
56
|
-
|
|
62
|
+
if (fd !== undefined)
|
|
63
|
+
fs.closeSync(fd);
|
|
64
|
+
// A failed write/rename must not strand sibling temp files.
|
|
65
|
+
try {
|
|
66
|
+
fs.rmSync(tmp, { force: true });
|
|
67
|
+
}
|
|
68
|
+
catch { /* best-effort cleanup */ }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Commit a fully-written sibling temp as the live adapter.
|
|
73
|
+
*
|
|
74
|
+
* For overwrite=false, hard-link creation is the atomic no-clobber primitive: it either
|
|
75
|
+
* creates finalPath pointing at the complete inode, or fails with EEXIST. Unlike an
|
|
76
|
+
* existsSync()+rename sequence, concurrent saves cannot both win. The temp is guaranteed to
|
|
77
|
+
* be on the same filesystem because it is created beside finalPath.
|
|
78
|
+
*/
|
|
79
|
+
function commitAdapterTemp(tmp, finalPath, overwrite) {
|
|
80
|
+
const dir = path.dirname(finalPath);
|
|
81
|
+
if (overwrite) {
|
|
82
|
+
fs.renameSync(tmp, finalPath);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
try {
|
|
86
|
+
fs.linkSync(tmp, finalPath);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err.code === 'EEXIST')
|
|
90
|
+
return false;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
57
94
|
}
|
|
58
|
-
fs.renameSync(tmp, finalPath);
|
|
59
95
|
fsyncDir(dir);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
function isPathInside(parent, candidate) {
|
|
99
|
+
const relative = path.relative(parent, candidate);
|
|
100
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
101
|
+
}
|
|
102
|
+
/** Resolve/create the adapter directory while refusing a site symlink or non-directory. */
|
|
103
|
+
function resolveSafeAdapterPath(site, command) {
|
|
104
|
+
const root = path.resolve(getUserClisDir());
|
|
105
|
+
fs.mkdirSync(root, { recursive: true });
|
|
106
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
107
|
+
const siteDir = path.join(root, site);
|
|
108
|
+
try {
|
|
109
|
+
fs.mkdirSync(siteDir, { mode: 0o700 });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err.code !== 'EEXIST')
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
const siteStat = fs.lstatSync(siteDir);
|
|
116
|
+
if (siteStat.isSymbolicLink() || !siteStat.isDirectory()) {
|
|
117
|
+
return { ok: false, reason: 'adapter site path must be a real directory under the byCLI config root' };
|
|
118
|
+
}
|
|
119
|
+
const canonicalSite = fs.realpathSync(siteDir);
|
|
120
|
+
if (!isPathInside(canonicalRoot, canonicalSite) || path.dirname(canonicalSite) !== canonicalRoot) {
|
|
121
|
+
return { ok: false, reason: 'adapter site path escapes the byCLI config root' };
|
|
122
|
+
}
|
|
123
|
+
const adapterPath = path.join(siteDir, `${command}.js`);
|
|
124
|
+
try {
|
|
125
|
+
const targetStat = fs.lstatSync(adapterPath);
|
|
126
|
+
if (targetStat.isSymbolicLink() || !targetStat.isFile()) {
|
|
127
|
+
return { ok: false, reason: 'adapter target must be a regular file' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
if (err.code !== 'ENOENT')
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, adapterPath };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* User adapters are filesystem-authoritative. Removing the optional compiled user manifest
|
|
138
|
+
* makes the next byCLI process fall back to scanning clis/, so a new or replaced adapter is
|
|
139
|
+
* discovered without a second daemon notification endpoint.
|
|
140
|
+
*/
|
|
141
|
+
function invalidateUserCliManifest() {
|
|
142
|
+
fs.rmSync(getCliManifestPath(getUserClisDir()), { force: true });
|
|
60
143
|
}
|
|
61
144
|
function writeManifest(manifestPath, manifest) {
|
|
62
145
|
atomicWrite(manifestPath, JSON.stringify(manifest));
|
|
@@ -131,16 +214,61 @@ export function saveAdapterSource(input) {
|
|
|
131
214
|
if (typeof input.source !== 'string' || !input.source.trim()) {
|
|
132
215
|
return { ok: false, errorCode: 'validation_failed', reason: 'source required' };
|
|
133
216
|
}
|
|
217
|
+
if (input.overwrite !== undefined && typeof input.overwrite !== 'boolean') {
|
|
218
|
+
return { ok: false, errorCode: 'validation_failed', reason: 'overwrite must be a boolean' };
|
|
219
|
+
}
|
|
134
220
|
const { site, command } = v.parts;
|
|
135
|
-
const
|
|
221
|
+
const resolved = resolveSafeAdapterPath(site, command);
|
|
222
|
+
if (!resolved.ok)
|
|
223
|
+
return { ok: false, errorCode: 'validation_failed', reason: resolved.reason };
|
|
224
|
+
const { adapterPath } = resolved;
|
|
136
225
|
const reportPath = path.join(getSiteRecorderDir(site), `${command}-report.json`);
|
|
137
226
|
const txnId = randomUUID();
|
|
138
227
|
const report = { adapterPath, reportPath, source: 'llm-generated', llmModel: input.llmModel ?? null, savedAt: Date.now() };
|
|
139
228
|
const reportJson = JSON.stringify(report, null, 2);
|
|
140
229
|
const header = buildProvenanceHeader({ txnId, reportPath, reportSha256: sha256(reportJson), llmModel: input.llmModel });
|
|
141
230
|
const rendered = input.source.startsWith('// @generated-by') ? input.source : `${header}\n${input.source}`;
|
|
231
|
+
const overwrite = input.overwrite ?? false;
|
|
232
|
+
const dir = path.dirname(adapterPath);
|
|
233
|
+
const tmp = path.join(dir, `.${path.basename(adapterPath)}.${randomUUID()}.tmp`);
|
|
234
|
+
let fd;
|
|
235
|
+
try {
|
|
236
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
237
|
+
fs.writeFileSync(fd, rendered);
|
|
238
|
+
fs.fsyncSync(fd);
|
|
239
|
+
fs.closeSync(fd);
|
|
240
|
+
fd = undefined;
|
|
241
|
+
// Re-check the directory after creating the temp, immediately before publication. This
|
|
242
|
+
// catches ordinary symlink replacement attempts; name validation plus sibling temp/link
|
|
243
|
+
// keeps the final path contained and the no-clobber decision atomic.
|
|
244
|
+
const canonicalRoot = fs.realpathSync(path.resolve(getUserClisDir()));
|
|
245
|
+
const canonicalDir = fs.realpathSync(dir);
|
|
246
|
+
if (fs.lstatSync(dir).isSymbolicLink() || path.dirname(canonicalDir) !== canonicalRoot) {
|
|
247
|
+
return { ok: false, errorCode: 'validation_failed', reason: 'adapter site path changed during save' };
|
|
248
|
+
}
|
|
249
|
+
// Invalidation is part of the same save operation. Do it before publishing so a successful
|
|
250
|
+
// adapter commit can never remain hidden behind a stale user manifest.
|
|
251
|
+
invalidateUserCliManifest();
|
|
252
|
+
if (!commitAdapterTemp(tmp, adapterPath, overwrite)) {
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
errorCode: 'adapter_exists',
|
|
256
|
+
reason: 'CLI adapter already exists',
|
|
257
|
+
adapterPath,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
if (fd !== undefined)
|
|
263
|
+
fs.closeSync(fd);
|
|
264
|
+
try {
|
|
265
|
+
fs.rmSync(tmp, { force: true });
|
|
266
|
+
}
|
|
267
|
+
catch { /* best-effort cleanup */ }
|
|
268
|
+
}
|
|
269
|
+
// The adapter file is authoritative; recorder metadata is refreshed only after the adapter
|
|
270
|
+
// has been atomically published, so an adapter_exists response never changes the report.
|
|
142
271
|
atomicWrite(reportPath, reportJson);
|
|
143
|
-
atomicWrite(adapterPath, rendered);
|
|
144
272
|
return { ok: true, adapterPath, reportPath };
|
|
145
273
|
}
|
|
146
274
|
/** First line of every recorder-generated adapter (buildProvenanceHeader). Recovery only
|
|
@@ -142,7 +142,33 @@ export async function handleVerify(ctx, body) {
|
|
|
142
142
|
const executionSeedArgs = body.executionSeedArgs && typeof body.executionSeedArgs === 'object' && !Array.isArray(body.executionSeedArgs)
|
|
143
143
|
? body.executionSeedArgs
|
|
144
144
|
: undefined;
|
|
145
|
-
const
|
|
145
|
+
const adapterPathRaw = body.adapterPath;
|
|
146
|
+
const expectedSourceSha256Raw = body.expectedSourceSha256;
|
|
147
|
+
const invalidAdapterPath = adapterPathRaw !== undefined
|
|
148
|
+
&& (typeof adapterPathRaw !== 'string' || adapterPathRaw.trim().length === 0);
|
|
149
|
+
const invalidExpectedSourceHash = expectedSourceSha256Raw !== undefined
|
|
150
|
+
&& (typeof expectedSourceSha256Raw !== 'string' || !/^[0-9a-f]{64}$/.test(expectedSourceSha256Raw));
|
|
151
|
+
if (invalidAdapterPath || invalidExpectedSourceHash) {
|
|
152
|
+
ctx.registry.finalizeRequest(requestId, {
|
|
153
|
+
status: 'failed',
|
|
154
|
+
error: errorBody('validation_failed', 'adapterPath or expectedSourceSha256 is invalid'),
|
|
155
|
+
});
|
|
156
|
+
return { status: 202, body: accepted(requestId) };
|
|
157
|
+
}
|
|
158
|
+
const adapterPath = typeof adapterPathRaw === 'string' ? adapterPathRaw : undefined;
|
|
159
|
+
const expectedSourceSha256 = typeof expectedSourceSha256Raw === 'string'
|
|
160
|
+
? expectedSourceSha256Raw
|
|
161
|
+
: undefined;
|
|
162
|
+
const input = {
|
|
163
|
+
name,
|
|
164
|
+
requestId,
|
|
165
|
+
sessionId,
|
|
166
|
+
executionSeedArgs,
|
|
167
|
+
fixture,
|
|
168
|
+
trace,
|
|
169
|
+
adapterPath,
|
|
170
|
+
expectedSourceSha256,
|
|
171
|
+
};
|
|
146
172
|
const result = await verifyAdapter(input, sessionHmacKey, ctx.runner);
|
|
147
173
|
if (!result.ok) {
|
|
148
174
|
ctx.registry.finalizeRequest(requestId, { status: 'failed', error: errorBody(result.errorCode, result.reason) });
|
|
@@ -236,7 +236,7 @@ export async function runVerifyRunner(input, emit, dependencies = {}) {
|
|
|
236
236
|
emit({
|
|
237
237
|
type: 'result', requestId: input.requestId, ok: false,
|
|
238
238
|
data: { stage: 'load', sourceSha256 },
|
|
239
|
-
error: { code: '
|
|
239
|
+
error: { code: 'validation_failed', message: 'adapter source hash does not match expected source' },
|
|
240
240
|
});
|
|
241
241
|
return;
|
|
242
242
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|