mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
3f4fd58937
commit
76c7d31c53
8 changed files with 211 additions and 28 deletions
14
.github/workflows/release-beta.yml
vendored
14
.github/workflows/release-beta.yml
vendored
|
|
@ -122,11 +122,12 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify mac Electron framework symlinks
|
||||
- name: Inspect mac Electron framework symlinks
|
||||
run: |
|
||||
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"));')"
|
||||
framework="$electron_dist/Electron.app/Contents/Frameworks/Electron Framework.framework"
|
||||
missing_links=0
|
||||
for link in \
|
||||
"$framework/Electron Framework" \
|
||||
"$framework/Helpers" \
|
||||
|
|
@ -134,12 +135,15 @@ jobs:
|
|||
"$framework/Resources" \
|
||||
"$framework/Versions/Current"; do
|
||||
if [ ! -L "$link" ]; then
|
||||
echo "Expected Electron framework symlink, got non-symlink: $link" >&2
|
||||
ls -la "$framework" >&2 || true
|
||||
ls -la "$framework/Versions" >&2 || true
|
||||
exit 1
|
||||
echo "::warning::Expected Electron framework symlink, got non-symlink: $link"
|
||||
missing_links=1
|
||||
fi
|
||||
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
|
||||
env:
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,56 @@ export function resolveSafeProjectAttachments(cwd, attachments, opts = {}) {
|
|||
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() {
|
||||
if (
|
||||
typeof process.resourcesPath === 'string' &&
|
||||
|
|
@ -3124,6 +3174,24 @@ function createSseErrorPayload(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 = {}) {
|
||||
const modelText = typeof model === 'string' && model.trim()
|
||||
? `"${model.trim()}"`
|
||||
|
|
@ -10648,13 +10716,24 @@ export async function startServer({
|
|||
}
|
||||
if (run.cancelRequested || design.runs.isTerminal(run.status)) return;
|
||||
|
||||
// Sanitise supplied image paths: must live under UPLOAD_DIR.
|
||||
const safeImages = imagePaths.filter((p) => {
|
||||
const resolved = path.resolve(p);
|
||||
return (
|
||||
resolved.startsWith(UPLOAD_DIR + path.sep) && fs.existsSync(resolved)
|
||||
// Sanitise supplied image paths: must live under UPLOAD_DIR and stay
|
||||
// below the prompt-image safety cap.
|
||||
const { safeImages, oversizedImages, failedImages } =
|
||||
resolveSafePromptImagePaths(imagePaths);
|
||||
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 =
|
||||
def.id === 'amr'
|
||||
? await stageAmrImagePaths(cwd ?? PROJECT_ROOT, safeImages, UPLOAD_DIR)
|
||||
|
|
@ -11937,14 +12016,18 @@ export async function startServer({
|
|||
const sendAgentEvent = (ev) => {
|
||||
if (ev?.type === 'error') {
|
||||
if (agentStreamError) return;
|
||||
agentStreamError = String(ev.message || 'Agent stream error');
|
||||
clearInactivityWatchdog();
|
||||
const failureText = [
|
||||
agentStreamError,
|
||||
String(ev.message || 'Agent stream error'),
|
||||
typeof ev.raw === 'string' ? ev.raw : '',
|
||||
agentStdoutTail,
|
||||
agentStderrTail,
|
||||
].join('\n');
|
||||
agentStreamError = rewriteKnownAgentStreamError(
|
||||
agentId,
|
||||
String(ev.message || 'Agent stream error'),
|
||||
failureText,
|
||||
);
|
||||
clearInactivityWatchdog();
|
||||
const authFailure = classifyAgentAuthFailure(agentId, failureText);
|
||||
if (authFailure?.status === 'missing') {
|
||||
send('error', createSseErrorPayload(
|
||||
|
|
@ -12374,6 +12457,19 @@ export async function startServer({
|
|||
detail || 'The model service returned an error.',
|
||||
{ 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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const previousRuntimeKey = process.env.VELA_RUNTIME_KEY;
|
||||
const previousLinkUrl = process.env.VELA_LINK_URL;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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', () => {
|
||||
expect(
|
||||
|
|
@ -21,3 +21,57 @@ test('selectPromptImagePaths keeps original paths for non-AMR agents', () => {
|
|||
),
|
||||
).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' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@
|
|||
# 1. Temporarily set the consuming `hash = lib.fakeHash;`
|
||||
# 2. Run the relevant nix build/flake check
|
||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||
daemonHash = "sha256-d7vCfXgAVtJzs+esw7zRtWquLJxQml2Y++UbnYWOuNk=";
|
||||
webHash = "sha256-kSBYrAkEKu7vB+2AQ+rrxINhP1iJut0alFuaa5RAqLM=";
|
||||
daemonHash = "sha256-nSMVyVSHfcXV5fLMXM3tfdQxZRb+FNF6P4iuJw/Z8Mo=";
|
||||
webHash = "sha256-QOufFb3Hb5js3jK6QEl3WfnxNAa4DdZfMKoALTHY4hI=";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli':
|
||||
specifier: 0.0.3
|
||||
version: 0.0.3
|
||||
specifier: 0.0.4
|
||||
version: 0.0.4
|
||||
|
||||
tools/serve:
|
||||
dependencies:
|
||||
|
|
@ -1642,13 +1642,13 @@ packages:
|
|||
'@posthog/types@1.374.2':
|
||||
resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.3':
|
||||
resolution: {integrity: sha512-tNFNiYVMfp64SHojvpcVV8zIw5RHgaqu8tLcI7SZdk1zS3dAv1gZ20ayebBpxWWAoj2wNMsuA6W006mRRRyjdQ==}
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.4':
|
||||
resolution: {integrity: sha512-PleB0cl42Iv7s+TrhsBFu1KZ0p3tQa+Qpm5PAS21p0INUU6kQ9H45DJ6bKB/CAoGuhq5lPL1XT3NdkLPe+V46A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@powerformer/vela-cli@0.0.3':
|
||||
resolution: {integrity: sha512-ZUrFNFJ/2gvSmQbXLPqRZCeeOR/bNyj0pxhJwKHxiAbGQWVNlTDFy9lDzyID3gD+UH3VMZacmbvSPQyhuRyxAQ==}
|
||||
'@powerformer/vela-cli@0.0.4':
|
||||
resolution: {integrity: sha512-63QzbvQ3JRkFwHf9cETAn4WTkbIjYc48Z4Et+pYFRyIwu3CtUUBxsPy93k/L9I1AcXOGjyJT7vFMm1gfs8amew==}
|
||||
hasBin: true
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
|
|
@ -6043,12 +6043,12 @@ snapshots:
|
|||
|
||||
'@posthog/types@1.374.2': {}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.3':
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.4':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli@0.0.3':
|
||||
'@powerformer/vela-cli@0.0.4':
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.3
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.4
|
||||
optional: true
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.60.2)':
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
"electron-builder": "26.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@powerformer/vela-cli": "0.0.3"
|
||||
"@powerformer/vela-cli": "0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ export async function runElectronBuilder(
|
|||
iconSize: 96,
|
||||
title: identity.installerTitle,
|
||||
},
|
||||
electronDist: config.electronDistPath,
|
||||
electronVersion: config.electronVersion,
|
||||
executableName: identity.executableName,
|
||||
extraMetadata: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue