chore: bump vela cli to 0.0.4 (#3239)

* chore: bump vela cli to 0.0.4-test.0

* chore: refresh lockfile for vela cli 0.0.4-test.0

* chore(nix): refresh pnpm deps hash

* fix: materialize electron before mac release checks

* fix: rebuild electron when mac framework links are invalid

* revert: drop release workflow experiments

* chore(nix): refresh pnpm deps hash

* fix: stop blocking beta mac release on electron symlink preflight

* fix: stop using custom electron dist for beta mac packaging

* fix: guard oversized chat images and opencode overflow

* chore: bump vela cli to 0.0.4

* chore(nix): refresh pnpm deps hash

* fix(daemon): surface prompt-image stat failures instead of dropping them

resolveSafePromptImagePaths only swallowed unresolvable path input; once a
path was confirmed inside UPLOAD_DIR and existed, a statSync failure
(EACCES/EPERM, a file vanishing mid-run) silently dropped the image and let
the run continue without that prompt context. Since this helper is now also
the 1 MB enforcement point, that turned an infra/validation failure into a
'successful' run with missing required context.

Collect those into a new failedImages bucket and fail the run with
INTERNAL_ERROR at the call site, mirroring the oversized-image guard. Add a
unit test covering statSync throwing.

---------

Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
Caprika 2026-05-29 14:41:17 +08:00 committed by GitHub
parent 3f4fd58937
commit 76c7d31c53
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 28 deletions

View file

@ -122,11 +122,12 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Verify mac Electron framework symlinks - name: Inspect mac Electron framework symlinks
run: | run: |
set -euo pipefail set -euo pipefail
electron_dist="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist"));')" electron_dist="$(node -e 'const path = require("node:path"); const { createRequire } = require("node:module"); const requireFromDesktop = createRequire(path.join(process.cwd(), "apps/desktop/package.json")); const electron = requireFromDesktop.resolve("electron"); process.stdout.write(path.join(path.dirname(electron), "dist"));')"
framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework" framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework"
missing_links=0
for link in \ for link in \
"$framework/Electron Framework" \ "$framework/Electron Framework" \
"$framework/Helpers" \ "$framework/Helpers" \
@ -134,12 +135,15 @@ jobs:
"$framework/Resources" \ "$framework/Resources" \
"$framework/Versions/Current"; do "$framework/Versions/Current"; do
if [ ! -L "$link" ]; then if [ ! -L "$link" ]; then
echo "Expected Electron framework symlink, got non-symlink: $link" >&2 echo "::warning::Expected Electron framework symlink, got non-symlink: $link"
ls -la "$framework" >&2 || true missing_links=1
ls -la "$framework/Versions" >&2 || true
exit 1
fi fi
done done
if [ "$missing_links" -ne 0 ]; then
ls -la "$framework" >&2 || true
ls -la "$framework/Versions" >&2 || true
echo "Continuing into tools-pack because electron-builder is the source of truth for whether packaging actually works."
fi
- name: Prepare Apple signing certificate - name: Prepare Apple signing certificate
env: env:

View file

@ -1054,6 +1054,56 @@ export function resolveSafeProjectAttachments(cwd, attachments, opts = {}) {
return out; return out;
} }
export function resolveSafePromptImagePaths(imagePaths, opts = {}) {
if (!Array.isArray(imagePaths) || imagePaths.length === 0) {
return { safeImages: [], oversizedImages: [], failedImages: [] };
}
const pathImpl = opts.pathImpl ?? path;
const existsSync = opts.existsSync ?? fs.existsSync;
const statSync = opts.statSync ?? fs.statSync;
const uploadDir = pathImpl.resolve(opts.uploadDir ?? UPLOAD_DIR);
const maxBytes = Number.isFinite(opts.maxBytes)
? Number(opts.maxBytes)
: MAX_CHAT_IMAGE_BYTES;
const safeImages = [];
const oversizedImages = [];
const failedImages = [];
for (const inputPath of imagePaths) {
if (typeof inputPath !== 'string' || inputPath.length === 0) continue;
let resolved;
try {
resolved = pathImpl.resolve(inputPath);
} catch {
// Drop malformed path input; we cannot even resolve it to a location.
continue;
}
if (!isPathWithin(uploadDir, resolved) || !existsSync(resolved)) continue;
// Past the within-UPLOAD_DIR + existence gate the path points at a real
// upload. A statSync failure here (EACCES/EPERM, a file that vanished
// mid-run) is an infrastructure error, not bad input — surface it so the
// run fails loudly instead of silently dropping required prompt context.
let stat;
try {
stat = statSync(resolved);
} catch (err) {
failedImages.push({
path: inputPath,
error: err instanceof Error ? err.message : String(err),
});
continue;
}
if (!stat.isFile()) continue;
if (typeof stat.size === 'number' && stat.size > maxBytes) {
oversizedImages.push({ path: inputPath, sizeBytes: stat.size });
continue;
}
safeImages.push(inputPath);
}
return { safeImages, oversizedImages, failedImages };
}
function resolveProcessResourcesPath() { function resolveProcessResourcesPath() {
if ( if (
typeof process.resourcesPath === 'string' && typeof process.resourcesPath === 'string' &&
@ -3124,6 +3174,24 @@ function createSseErrorPayload(code, message, init = {}) {
return { message, error: createCompatApiError(code, message, init) }; return { message, error: createCompatApiError(code, message, init) };
} }
const MAX_CHAT_IMAGE_BYTES = 1024 * 1024;
function rewriteKnownAgentStreamError(agentId, message, failureText = '') {
const rawMessage =
typeof message === 'string' && message.trim()
? message.trim()
: 'Agent stream error';
const combined = `${rawMessage}\n${failureText}`;
if (
/bufio\.scanner:\s*token too long/i.test(combined) &&
/opencode/i.test(combined) &&
(agentId === 'opencode' || agentId === 'amr' || /json-rpc id \d+/i.test(combined))
) {
return 'The run failed due to an unknown upstream streaming error. Please retry.';
}
return rawMessage;
}
function createAmrModelUnavailablePayload(model, init = {}) { function createAmrModelUnavailablePayload(model, init = {}) {
const modelText = typeof model === 'string' && model.trim() const modelText = typeof model === 'string' && model.trim()
? `"${model.trim()}"` ? `"${model.trim()}"`
@ -10648,13 +10716,24 @@ export async function startServer({
} }
if (run.cancelRequested || design.runs.isTerminal(run.status)) return; if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
// Sanitise supplied image paths: must live under UPLOAD_DIR. // Sanitise supplied image paths: must live under UPLOAD_DIR and stay
const safeImages = imagePaths.filter((p) => { // below the prompt-image safety cap.
const resolved = path.resolve(p); const { safeImages, oversizedImages, failedImages } =
return ( resolveSafePromptImagePaths(imagePaths);
resolved.startsWith(UPLOAD_DIR + path.sep) && fs.existsSync(resolved) if (oversizedImages.length > 0) {
return design.runs.fail(
run,
'BAD_REQUEST',
'Image attachments must be 1 MB or smaller.',
); );
}); }
if (failedImages.length > 0) {
return design.runs.fail(
run,
'INTERNAL_ERROR',
'Failed to read one or more image attachments.',
);
}
const amrStagedImages = const amrStagedImages =
def.id === 'amr' def.id === 'amr'
? await stageAmrImagePaths(cwd ?? PROJECT_ROOT, safeImages, UPLOAD_DIR) ? await stageAmrImagePaths(cwd ?? PROJECT_ROOT, safeImages, UPLOAD_DIR)
@ -11937,14 +12016,18 @@ export async function startServer({
const sendAgentEvent = (ev) => { const sendAgentEvent = (ev) => {
if (ev?.type === 'error') { if (ev?.type === 'error') {
if (agentStreamError) return; if (agentStreamError) return;
agentStreamError = String(ev.message || 'Agent stream error');
clearInactivityWatchdog();
const failureText = [ const failureText = [
agentStreamError, String(ev.message || 'Agent stream error'),
typeof ev.raw === 'string' ? ev.raw : '', typeof ev.raw === 'string' ? ev.raw : '',
agentStdoutTail, agentStdoutTail,
agentStderrTail, agentStderrTail,
].join('\n'); ].join('\n');
agentStreamError = rewriteKnownAgentStreamError(
agentId,
String(ev.message || 'Agent stream error'),
failureText,
);
clearInactivityWatchdog();
const authFailure = classifyAgentAuthFailure(agentId, failureText); const authFailure = classifyAgentAuthFailure(agentId, failureText);
if (authFailure?.status === 'missing') { if (authFailure?.status === 'missing') {
send('error', createSseErrorPayload( send('error', createSseErrorPayload(
@ -12374,6 +12457,19 @@ export async function startServer({
detail || 'The model service returned an error.', detail || 'The model service returned an error.',
{ retryable: true }, { retryable: true },
)); ));
} else {
const rewritten = rewriteKnownAgentStreamError(
def.id,
(agentStderrTail || agentStdoutTail || '').trim(),
`${agentStderrTail}\n${agentStdoutTail}`,
);
if (rewritten !== 'Agent stream error') {
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
rewritten,
{ retryable: true },
));
}
} }
} }
// Reconcile any HTML artifacts that were written during this run // Reconcile any HTML artifacts that were written during this run

View file

@ -216,6 +216,36 @@ process.exit(0);
); );
}); });
it('rewrites the OpenCode scanner overflow into a generic retry message', async () => {
const conversationId = `conv-${randomUUID()}`;
await withFakeAgent(
'opencode',
`
process.stderr.write('json-rpc id 4: opencode event stream: read opencode SSE: bufio.Scanner: token too long\\n');
process.exit(1);
`,
async () => {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'opencode',
conversationId,
message: 'hello',
}),
});
const body = await response.text();
expect(response.ok).toBe(true);
expect(body).toContain('AGENT_EXECUTION_FAILED');
expect(body).toContain('The run failed due to an unknown upstream streaming error. Please retry.');
expect(body).toContain('event: stderr');
expect(body).toContain('"status":"failed"');
},
);
});
it('retries transient AMR Link catalog failures before aborting startup', async () => { it('retries transient AMR Link catalog failures before aborting startup', async () => {
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY; const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
const previousLinkUrl = process.env.VELA_LINK_URL; const previousLinkUrl = process.env.VELA_LINK_URL;

View file

@ -1,6 +1,6 @@
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { selectPromptImagePaths } from '../src/server.js'; import { resolveSafePromptImagePaths, selectPromptImagePaths } from '../src/server.js';
test('selectPromptImagePaths uses staged AMR paths in prompt text', () => { test('selectPromptImagePaths uses staged AMR paths in prompt text', () => {
expect( expect(
@ -21,3 +21,57 @@ test('selectPromptImagePaths keeps original paths for non-AMR agents', () => {
), ),
).toEqual(['/tmp/od-uploads/original.png']); ).toEqual(['/tmp/od-uploads/original.png']);
}); });
test('resolveSafePromptImagePaths rejects images larger than 1 MB', () => {
const result = resolveSafePromptImagePaths(
['/tmp/od-uploads/too-large.png', '/tmp/od-uploads/ok.png'],
{
uploadDir: '/tmp/od-uploads',
existsSync: () => true,
statSync: (inputPath: string) => ({
isFile: () => true,
size: inputPath.endsWith('too-large.png') ? 1024 * 1024 + 1 : 1024,
}),
},
);
expect(result.safeImages).toEqual(['/tmp/od-uploads/ok.png']);
expect(result.oversizedImages).toEqual([
{ path: '/tmp/od-uploads/too-large.png', sizeBytes: 1024 * 1024 + 1 },
]);
});
test('resolveSafePromptImagePaths keeps images at or below 1 MB', () => {
const result = resolveSafePromptImagePaths(
['/tmp/od-uploads/exactly-1mb.png'],
{
uploadDir: '/tmp/od-uploads',
existsSync: () => true,
statSync: () => ({
isFile: () => true,
size: 1024 * 1024,
}),
},
);
expect(result.safeImages).toEqual(['/tmp/od-uploads/exactly-1mb.png']);
expect(result.oversizedImages).toEqual([]);
});
test('resolveSafePromptImagePaths surfaces stat failures instead of dropping the image', () => {
const result = resolveSafePromptImagePaths(['/tmp/od-uploads/unreadable.png'], {
uploadDir: '/tmp/od-uploads',
existsSync: () => true,
statSync: () => {
throw Object.assign(new Error('EACCES: permission denied'), {
code: 'EACCES',
});
},
});
expect(result.safeImages).toEqual([]);
expect(result.oversizedImages).toEqual([]);
expect(result.failedImages).toEqual([
{ path: '/tmp/od-uploads/unreadable.png', error: 'EACCES: permission denied' },
]);
});

View file

@ -9,6 +9,6 @@
# 1. Temporarily set the consuming `hash = lib.fakeHash;` # 1. Temporarily set the consuming `hash = lib.fakeHash;`
# 2. Run the relevant nix build/flake check # 2. Run the relevant nix build/flake check
# 3. Copy the expected hash printed by Nix into the matching field below # 3. Copy the expected hash printed by Nix into the matching field below
daemonHash = "sha256-d7vCfXgAVtJzs+esw7zRtWquLJxQml2Y++UbnYWOuNk="; daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
webHash = "sha256-kSBYrAkEKu7vB+2AQ+rrxINhP1iJut0alFuaa5RAqLM="; webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
} }

View file

@ -601,8 +601,8 @@ importers:
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))
optionalDependencies: optionalDependencies:
'@powerformer/vela-cli': '@powerformer/vela-cli':
specifier: 0.0.3 specifier: 0.0.4
version: 0.0.3 version: 0.0.4
tools/serve: tools/serve:
dependencies: dependencies:
@ -1642,13 +1642,13 @@ packages:
'@posthog/types@1.374.2': '@posthog/types@1.374.2':
resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==} resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==}
'@powerformer/vela-cli-darwin-arm64@0.0.3': '@powerformer/vela-cli-darwin-arm64@0.0.4':
resolution: {integrity: sha512-tNFNiYVMfp64SHojvpcVV8zIw5RHgaqu8tLcI7SZdk1zS3dAv1gZ20ayebBpxWWAoj2wNMsuA6W006mRRRyjdQ==} resolution: {integrity: sha512-PleB0cl42Iv7s+TrhsBFu1KZ0p3tQa+Qpm5PAS21p0INUU6kQ9H45DJ6bKB/CAoGuhq5lPL1XT3NdkLPe+V46A==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@powerformer/vela-cli@0.0.3': '@powerformer/vela-cli@0.0.4':
resolution: {integrity: sha512-ZUrFNFJ/2gvSmQbXLPqRZCeeOR/bNyj0pxhJwKHxiAbGQWVNlTDFy9lDzyID3gD+UH3VMZacmbvSPQyhuRyxAQ==} resolution: {integrity: sha512-63QzbvQ3JRkFwHf9cETAn4WTkbIjYc48Z4Et+pYFRyIwu3CtUUBxsPy93k/L9I1AcXOGjyJT7vFMm1gfs8amew==}
hasBin: true hasBin: true
'@rollup/pluginutils@5.3.0': '@rollup/pluginutils@5.3.0':
@ -6043,12 +6043,12 @@ snapshots:
'@posthog/types@1.374.2': {} '@posthog/types@1.374.2': {}
'@powerformer/vela-cli-darwin-arm64@0.0.3': '@powerformer/vela-cli-darwin-arm64@0.0.4':
optional: true optional: true
'@powerformer/vela-cli@0.0.3': '@powerformer/vela-cli@0.0.4':
optionalDependencies: optionalDependencies:
'@powerformer/vela-cli-darwin-arm64': 0.0.3 '@powerformer/vela-cli-darwin-arm64': 0.0.4
optional: true optional: true
'@rollup/pluginutils@5.3.0(rollup@4.60.2)': '@rollup/pluginutils@5.3.0(rollup@4.60.2)':

View file

@ -22,7 +22,7 @@
"electron-builder": "26.8.1" "electron-builder": "26.8.1"
}, },
"optionalDependencies": { "optionalDependencies": {
"@powerformer/vela-cli": "0.0.3" "@powerformer/vela-cli": "0.0.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "24.12.2", "@types/node": "24.12.2",

View file

@ -104,7 +104,6 @@ export async function runElectronBuilder(
iconSize: 96, iconSize: 96,
title: identity.installerTitle, title: identity.installerTitle,
}, },
electronDist: config.electronDistPath,
electronVersion: config.electronVersion, electronVersion: config.electronVersion,
executableName: identity.executableName, executableName: identity.executableName,
extraMetadata: { extraMetadata: {