Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 491ce1d1f0 | |||
| a23c0217ae | |||
| 5c989fef2f | |||
| 5e5e10a8dc | |||
| 52a31072a3 | |||
| 7b40a03c45 | |||
| e9d32c09d3 | |||
| e89f0ce605 | |||
| 1278d57c12 |
@@ -29,6 +29,14 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "workspace/**"
|
||||
# mc#1578 / a05add29 cure: build_runtime_package.py owns PYPROJECT_TEMPLATE
|
||||
# (deps, classifiers, project metadata). A change there is publish-affecting
|
||||
# even when workspace/** is untouched, so the autobump must fire to claim
|
||||
# the next runtime-v$VERSION tag. Without this, manual tagging races PyPI
|
||||
# (e.g. runtime-v0.1.18 collided with the 2026-04-27 PyPI 0.1.18 publish,
|
||||
# blocking the python-multipart pin from reaching prod).
|
||||
- "scripts/build_runtime_package.py"
|
||||
- "scripts/test_build_runtime_package.py"
|
||||
# Bump-and-tag on main/staging push (the actual operational trigger).
|
||||
push:
|
||||
branches:
|
||||
@@ -36,6 +44,8 @@ on:
|
||||
- staging
|
||||
paths:
|
||||
- "workspace/**"
|
||||
- "scripts/build_runtime_package.py"
|
||||
- "scripts/test_build_runtime_package.py"
|
||||
# Manual dispatch — useful when Gitea Actions API (/actions/*) is
|
||||
# unreachable (e.g. act_runner 404 on Gitea 1.22.6) and we cannot
|
||||
# re-trigger via curl.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# Consolidated comment dispatcher for manual review/tier refires.
|
||||
# DEPRECATED — superseded by `.gitea/workflows/sop-checklist.yml`.
|
||||
#
|
||||
# The review-refire logic (qa/security/tier slash-command dispatch) has been
|
||||
# merged into sop-checklist.yml as the `review-refire` job. This workflow
|
||||
# is kept as a no-op stub to avoid a gap during the transition window where
|
||||
# this file may be deleted while sop-checklist.yml has not yet been merged.
|
||||
#
|
||||
# After sop-checklist.yml lands, this file will be deleted (issue #1280).
|
||||
#
|
||||
# Historical behavior (superseded):
|
||||
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
|
||||
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
|
||||
# qa-review, security-review, sop-checklist, and sop-tier-refire all
|
||||
# listened to comments. This workflow is the single non-SOP comment subscriber:
|
||||
# ordinary comments no-op quickly; slash commands post the required status
|
||||
# contexts to the PR head SHA.
|
||||
# evaluating job-level `if:`. Previously this workflow was the single
|
||||
# non-SOP comment subscriber for qa/security/tier refire slash commands.
|
||||
|
||||
name: review-refire-comments
|
||||
|
||||
@@ -23,91 +28,12 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# No-op stub — all refire logic moved to sop-checklist.yml review-refire job.
|
||||
# Kept to avoid transition gap; will be deleted after sop-checklist.yml merges.
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Classify comment
|
||||
id: classify
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
IS_PR: ${{ github.event.issue.pull_request != null }}
|
||||
- name: Deprecated — refire logic moved to sop-checklist.yml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "run_qa=false"
|
||||
echo "run_security=false"
|
||||
echo "run_tier=false"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if [ "$IS_PR" != "true" ]; then
|
||||
echo "::notice::not a PR comment; no-op"
|
||||
exit 0
|
||||
fi
|
||||
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
|
||||
case "$first_line" in
|
||||
/qa-recheck*)
|
||||
echo "run_qa=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/security-recheck*)
|
||||
echo "run_security=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/refire-tier-check*)
|
||||
echo "run_tier=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "::notice::no supported review refire slash command; no-op"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out BASE ref for trusted scripts
|
||||
if: |
|
||||
steps.classify.outputs.run_qa == 'true' ||
|
||||
steps.classify.outputs.run_security == 'true' ||
|
||||
steps.classify.outputs.run_tier == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Refire qa-review status
|
||||
if: steps.classify.outputs.run_qa == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire security-review status
|
||||
if: steps.classify.outputs.run_security == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire sop-tier-check status
|
||||
if: steps.classify.outputs.run_tier == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
||||
SOP_DEBUG: '0'
|
||||
run: bash .gitea/scripts/sop-tier-refire.sh
|
||||
echo "::warning::review-refire-comments.yml is deprecated. Refire logic is now in sop-checklist.yml review-refire job. This workflow is a no-op stub pending deletion (issue #1280)."
|
||||
exit 0
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
#
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# === DESIGN ===
|
||||
# === CONSOLIDATION (issue #1280) ===
|
||||
#
|
||||
# Goal: each PR must answer 7 SOP-checklist questions in its body,
|
||||
# and each item must have at least one /sop-ack <slug> comment from
|
||||
# a non-author peer in the required team. BP requires the
|
||||
# `sop-checklist / all-items-acked (pull_request)` status to merge.
|
||||
# This workflow is the SINGLE `issue_comment` subscriber — the logic from
|
||||
# `review-refire-comments.yml` has been merged in. Before this change:
|
||||
# - sop-checklist.yml (pre-2026-05-16) → issue_comment:[created,edited,deleted] → runner slot used, job no-oped
|
||||
# - review-refire-comments.yml → issue_comment:[created] → runner slot used, job no-oped
|
||||
# → every non-refire comment occupied 2 runner slots for ~800 s each
|
||||
# (~650 no-op runs/day, ~1,300 runner-slot-occupancy-hours/day).
|
||||
#
|
||||
# Triggers:
|
||||
# - `pull_request_target`: opened, edited, synchronize, reopened
|
||||
# → fires when PR opens, body is edited (refire — RFC#351 §4),
|
||||
# or new code is pushed (head.sha changes → stale status would
|
||||
# be auto-discarded by BP via dismiss_stale_reviews, but the
|
||||
# status itself is per-SHA so we re-post on the new head).
|
||||
# - `issue_comment`: created, edited, deleted
|
||||
# → fires on any new comment so /sop-ack / /sop-revoke take
|
||||
# effect immediately (Gitea 1.22.6 doesn't refire on
|
||||
# pull_request_review per feedback_pull_request_review_no_refire,
|
||||
# so issue_comment is the canonical refire channel).
|
||||
# Fix (PR #1345 / issue #1280):
|
||||
# - ONE workflow, ONE issue_comment:[created] subscription (no edited/deleted)
|
||||
# - all-items-acked job: pull_request_target OR sop slash-command comments
|
||||
# - review-refire job: qa/security/tier refire slash commands
|
||||
# → ~50% reduction in comment-triggered runner occupancy vs pre-fix.
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
|
||||
# `pull_request_target` (not `pull_request`) — workflow def is loaded
|
||||
@@ -51,7 +47,7 @@
|
||||
# /sop-ack <slug-or-numeric-alias> [optional note]
|
||||
# — register a peer-ack for one checklist item.
|
||||
# — slug accepts kebab-case, snake_case, or natural-spaces
|
||||
# (all normalize to canonical kebab-case).
|
||||
# (all normalized to canonical kebab-case).
|
||||
# — numeric 1..7 maps via config.items[*].numeric_alias.
|
||||
# — most-recent (user, slug) directive wins.
|
||||
#
|
||||
@@ -61,6 +57,13 @@
|
||||
# — most-recent (user, slug) directive wins, so a later /sop-ack
|
||||
# re-restores the ack.
|
||||
#
|
||||
# /sop-n/a <gate> [reason]
|
||||
# — declare a gate (qa-review, security-review) N/A.
|
||||
# — see sop-checklist-config.yaml n/a_gates section.
|
||||
#
|
||||
# /qa-recheck /security-recheck /refire-tier-check
|
||||
# — refire the corresponding status check on the PR head.
|
||||
#
|
||||
# The eval is read-only + idempotent (read PR + comments + team
|
||||
# membership, compute, post status). Re-running on any event is safe —
|
||||
# the new status overwrites the previous one for the same context.
|
||||
@@ -79,7 +82,10 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
|
||||
issue_comment:
|
||||
types: [created, edited, deleted]
|
||||
types: [created] # NOT [created, edited, deleted] — Gitea 1.22.6 holds a runner slot
|
||||
# at job-parsing time, before job-level if: guards run. edited/deleted events
|
||||
# occupied ~1,300 runner-slot-hours/day on this workflow alone during the
|
||||
# 2026-05-16 freeze. Per PR #1345 fix.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -88,10 +94,10 @@ permissions:
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
# sop-checklist gate: runs on PR lifecycle events OR sop slash commands.
|
||||
# All other comment types (no-op text comments) no longer assign a runner
|
||||
# because this job's if: guard short-circuits before runner assignment.
|
||||
all-items-acked:
|
||||
# Run on pull_request_target events always. On issue_comment events,
|
||||
# only when the comment is on a PR (issue_comment fires for issues
|
||||
# too) and the body contains one of the slash-commands.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
@@ -125,3 +131,95 @@ jobs:
|
||||
--pr "$PR_NUMBER" \
|
||||
--config .gitea/sop-checklist-config.yaml \
|
||||
--gitea-host git.moleculesai.app
|
||||
|
||||
# bp-exempt: informational refire handler, not a merge gate. Emits
|
||||
# qa-review/security-review status updates on /qa-recheck et al slash commands.
|
||||
review-refire:
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Classify comment
|
||||
id: classify
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
{
|
||||
echo "run_qa=false"
|
||||
echo "run_security=false"
|
||||
echo "run_tier=false"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
|
||||
case "$first_line" in
|
||||
/qa-recheck*)
|
||||
echo "run_qa=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/security-recheck*)
|
||||
echo "run_security=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
/refire-tier-check*)
|
||||
echo "run_tier=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "::notice::no supported review refire slash command; no-op"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Check out BASE ref for trusted scripts
|
||||
if: |
|
||||
steps.classify.outputs.run_qa == 'true' ||
|
||||
steps.classify.outputs.run_security == 'true' ||
|
||||
steps.classify.outputs.run_tier == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Refire qa-review status
|
||||
if: steps.classify.outputs.run_qa == 'true'
|
||||
env:
|
||||
# RFC_324_TEAM_READ_TOKEN is read-only (team membership read scope only).
|
||||
# review-refire-status.sh POSTs to /statuses — requires write scope.
|
||||
# SOP_TIER_CHECK_TOKEN carries write:repository + write:issue + read:organization.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire security-review status
|
||||
if: steps.classify.outputs.run_security == 'true'
|
||||
env:
|
||||
# RFC_324_TEAM_READ_TOKEN is read-only (team membership read scope only).
|
||||
# review-refire-status.sh POSTs to /statuses — requires write scope.
|
||||
# SOP_TIER_CHECK_TOKEN carries write:repository + write:issue + read:organization.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
REVIEW_CHECK_STRICT: '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
.gitea/scripts/review-refire-status.sh
|
||||
|
||||
- name: Refire sop-tier-check status
|
||||
if: steps.classify.outputs.run_tier == 'true'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
SOP_DEBUG: '0'
|
||||
run: bash .gitea/scripts/sop-tier-refire.sh
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
trigger
|
||||
trigger
|
||||
retrigger 2026-05-20T04:09Z after op-config#110 (HOME=/home/runner) deploy to fleet — internal#603
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
uploadChatFiles,
|
||||
FileTooLargeError,
|
||||
MAX_UPLOAD_BYTES,
|
||||
computeUploadTimeoutMs,
|
||||
} from "../uploads";
|
||||
|
||||
// Tests for the 100 MB upload-cap raise + correct-reason error mapping
|
||||
// (CTO 2026-05-19 directive on forensic a99ab0a1: "if its file size
|
||||
// issue, should have error that instead saying timeout which is
|
||||
// wrong"). Each case has its own specific reason; conflation is the
|
||||
// bug this PR fixes.
|
||||
|
||||
// File constructor in node's vitest env supports size via array length.
|
||||
// Allocate a typed-array of N bytes and wrap it — File reads .size off
|
||||
// the underlying Blob. Allocating 101 MB once per test is fine (vitest
|
||||
// maxWorkers=1, single test process).
|
||||
function makeFile(name: string, size: number): File {
|
||||
const buf = new Uint8Array(size);
|
||||
return new File([buf], name);
|
||||
}
|
||||
|
||||
const wsId = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
describe("uploadChatFiles — MAX_UPLOAD_BYTES + pre-flight gate", () => {
|
||||
it("MAX_UPLOAD_BYTES is exactly 100 MB (mirrors server constant)", () => {
|
||||
// Pinned so a regression that flipped the constant back to 50 MB
|
||||
// would fail loudly here — without this the canvas would
|
||||
// silently start rejecting files the server now accepts.
|
||||
expect(MAX_UPLOAD_BYTES).toBe(100 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("throws FileTooLargeError for a 101 MB file BEFORE any network I/O", async () => {
|
||||
const oversize = makeFile("big.bin", 101 * 1024 * 1024);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
try {
|
||||
await uploadChatFiles(wsId, [oversize]);
|
||||
throw new Error("expected uploadChatFiles to throw, but it resolved");
|
||||
} catch (e) {
|
||||
// The exact class name matters — useChatSend's mapUploadErrorToReason
|
||||
// routes off `instanceof FileTooLargeError`. A regression that
|
||||
// demoted to a plain Error would silently re-introduce the
|
||||
// wrong-reason conflation CTO flagged.
|
||||
expect(e).toBeInstanceOf(FileTooLargeError);
|
||||
const err = e as FileTooLargeError;
|
||||
// Message must contain the 100MB cap (so the user knows what the
|
||||
// limit is) and a number-with-MB form of the actual size.
|
||||
expect(err.message).toContain("100MB");
|
||||
// Some toFixed(1) renderings: 101.0MB. Loose match: contains "MB".
|
||||
expect(err.message).toMatch(/got\s+\d+(\.\d+)?MB/);
|
||||
expect(err.fileSize).toBe(101 * 1024 * 1024);
|
||||
}
|
||||
// CRITICAL: no fetch may have been initiated. Pre-flight is the
|
||||
// whole point — if a network round-trip happened we'd be back to
|
||||
// surfacing a downstream timeout / 413 instead of the actionable
|
||||
// file-size message.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("accepts a file exactly at the cap (== MAX_UPLOAD_BYTES)", async () => {
|
||||
// Equality must NOT trip the gate — the cap is inclusive on the
|
||||
// server side and the canvas must match. Without this, an exact-
|
||||
// cap file would 503 client-side while the server accepts it.
|
||||
const exact = makeFile("max.bin", MAX_UPLOAD_BYTES);
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ files: [] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
await expect(uploadChatFiles(wsId, [exact])).resolves.toBeDefined();
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeUploadTimeoutMs — scaled timeout curve", () => {
|
||||
it("100 KB file → 60s floor (small-file ergonomics)", () => {
|
||||
// Below the floor, the small-file UX (typo'd hostname surfacing as
|
||||
// connect-error quickly) takes priority over the slow-uplink
|
||||
// assumption.
|
||||
expect(computeUploadTimeoutMs(100 * 1024)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("1 MB file → 60s floor", () => {
|
||||
expect(computeUploadTimeoutMs(1 * 1024 * 1024)).toBe(60_000);
|
||||
});
|
||||
|
||||
it("100 MB file → ~1000s (matches the slow-uplink design budget)", () => {
|
||||
// Pin the upper-bound case the design targets: at 100 MB / 100 KB/s
|
||||
// a legitimate slow uplink completes in ~1000s, comfortably
|
||||
// before the platform's 1200s http.Client timeout. Without this
|
||||
// scaling, the previous fixed 60s deadline aborted Ryan's ~60 MB
|
||||
// upload in forensic a99ab0a1.
|
||||
const ms = computeUploadTimeoutMs(100 * 1024 * 1024);
|
||||
// 100*1024*1024 / 100 = 1048576 ms ≈ 1048.6s — pin to ±1ms.
|
||||
expect(ms).toBe(Math.floor((100 * 1024 * 1024) / 100));
|
||||
expect(ms).toBeGreaterThan(1_000_000);
|
||||
expect(ms).toBeLessThan(1_100_000);
|
||||
});
|
||||
|
||||
it("strictly monotonic above the floor", () => {
|
||||
// A regression that capped or non-monotonised the curve would
|
||||
// silently re-introduce premature aborts for mid-size files.
|
||||
const a = computeUploadTimeoutMs(10 * 1024 * 1024);
|
||||
const b = computeUploadTimeoutMs(50 * 1024 * 1024);
|
||||
const c = computeUploadTimeoutMs(100 * 1024 * 1024);
|
||||
expect(b).toBeGreaterThan(a);
|
||||
expect(c).toBeGreaterThan(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadChatFiles — error path shapes (for downstream reason-mapping)", () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
afterEach(() => {
|
||||
fetchSpy?.mockRestore();
|
||||
fetchSpy = null;
|
||||
});
|
||||
|
||||
it("propagates the server's 413 reason verbatim (not as 'timeout')", async () => {
|
||||
// The error message text is what useChatSend surfaces via
|
||||
// `Upload failed: ${e.message}` — pin that the server's reason
|
||||
// is present, not swallowed.
|
||||
fetchSpy!.mockResolvedValue(
|
||||
new Response('{"error":"file exceeds per-file limit (100 MB)"}', {
|
||||
status: 413,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const f = makeFile("small.bin", 1024);
|
||||
await expect(uploadChatFiles(wsId, [f])).rejects.toThrow(
|
||||
/upload failed:.*413.*per-file limit/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates AbortSignal timeout as a DOMException with name=TimeoutError", async () => {
|
||||
// Reason-routing in useChatSend.mapUploadErrorToReason discriminates
|
||||
// by e.name === 'TimeoutError'. Pin the shape so a future browser /
|
||||
// polyfill change that renamed it would fail loudly here, NOT
|
||||
// silently fall through to the generic "Upload failed" path
|
||||
// (which is what made forensic a99ab0a1 hard to root-cause).
|
||||
const abortErr = new DOMException("signal timed out", "TimeoutError");
|
||||
fetchSpy!.mockRejectedValue(abortErr);
|
||||
const f = makeFile("small.bin", 1024);
|
||||
try {
|
||||
await uploadChatFiles(wsId, [f]);
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(DOMException);
|
||||
expect((e as DOMException).name).toBe("TimeoutError");
|
||||
// CRITICAL negative: the rejection must NOT be a
|
||||
// FileTooLargeError, because pre-flight already excluded that.
|
||||
expect(e).not.toBeInstanceOf(FileTooLargeError);
|
||||
}
|
||||
});
|
||||
|
||||
it("a 50 MB file does NOT trip the pre-flight gate (sub-cap)", async () => {
|
||||
// The forensic case: Ryan's file was over the OLD 50MB cap but
|
||||
// under the NEW 100MB cap. Pin that the pre-flight does NOT
|
||||
// misfire on a sub-100MB file.
|
||||
fetchSpy!.mockResolvedValue(
|
||||
new Response('{"files":[]}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const f = makeFile("ryan.bin", 50 * 1024 * 1024);
|
||||
await expect(uploadChatFiles(wsId, [f])).resolves.toBeDefined();
|
||||
expect(fetchSpy!).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mapUploadErrorToReason } from "../useChatSend";
|
||||
import { FileTooLargeError } from "../../uploads";
|
||||
|
||||
// Pin the case-by-case error mapping (CTO 2026-05-19 directive on
|
||||
// forensic a99ab0a1: each cause maps to ITS OWN message, no
|
||||
// conflation). The four cases — FileTooLargeError, TimeoutError,
|
||||
// other Error, non-Error — are the entire user-facing contract this
|
||||
// PR ships; each gets a dedicated assertion so a regression that
|
||||
// re-conflated them would surface here.
|
||||
|
||||
describe("mapUploadErrorToReason", () => {
|
||||
it("FileTooLargeError → surfaces the pre-flight message verbatim", () => {
|
||||
const err = new FileTooLargeError(
|
||||
101 * 1024 * 1024,
|
||||
"File too large (got 101.0MB) — limit is 100MB. Please use a smaller file.",
|
||||
);
|
||||
const out = mapUploadErrorToReason(err);
|
||||
// Verbatim, no "Upload failed:" prefix — the FileTooLargeError
|
||||
// message is already a complete, user-facing sentence.
|
||||
expect(out).toBe(err.message);
|
||||
expect(out).not.toMatch(/^Upload failed:/);
|
||||
// Must mention the cap so the user knows what to aim for.
|
||||
expect(out).toContain("100MB");
|
||||
// Must NOT mention timeout — wrong-reason conflation guard.
|
||||
expect(out.toLowerCase()).not.toContain("timeout");
|
||||
expect(out.toLowerCase()).not.toContain("connection");
|
||||
});
|
||||
|
||||
it("TimeoutError → connection-too-slow message, NOT file-size", () => {
|
||||
const err = new DOMException("signal timed out", "TimeoutError");
|
||||
const out = mapUploadErrorToReason(err);
|
||||
// The user-facing reason matches the design contract: tells the
|
||||
// user the connection is slow, gives them the actionable retry
|
||||
// hint, and does NOT mention file-size (pre-flight already
|
||||
// excluded that — this is the case CTO flagged).
|
||||
expect(out).toContain("Upload timed out");
|
||||
expect(out).toContain("connection is too slow");
|
||||
// CRITICAL negatives — guard against the wrong-reason conflation.
|
||||
expect(out).not.toMatch(/100MB|file too large|File too large/);
|
||||
});
|
||||
|
||||
it("plain Error from server (e.g. 413) → wraps with 'Upload failed:' + server reason", () => {
|
||||
// What uploadChatFiles throws when res.ok is false. The message
|
||||
// already encodes the status + body; the mapper just prefixes
|
||||
// "Upload failed:" so the chat error banner makes sense.
|
||||
const err = new Error("upload failed: 413 file exceeds per-file limit");
|
||||
const out = mapUploadErrorToReason(err);
|
||||
expect(out).toBe("Upload failed: upload failed: 413 file exceeds per-file limit");
|
||||
// Server's actual reason must survive — that's the whole
|
||||
// feedback_surface_actionable_failure_reason_to_user point.
|
||||
expect(out).toContain("413");
|
||||
expect(out).toContain("per-file limit");
|
||||
});
|
||||
|
||||
it("non-Error throw → generic fallback", () => {
|
||||
// A string-throw (or a frozen object) is unusual but possible in
|
||||
// some catch paths. The fallback must NOT crash and must still
|
||||
// give the user a non-empty reason.
|
||||
expect(mapUploadErrorToReason("some random string")).toBe("Upload failed");
|
||||
expect(mapUploadErrorToReason(undefined)).toBe("Upload failed");
|
||||
expect(mapUploadErrorToReason(null)).toBe("Upload failed");
|
||||
expect(mapUploadErrorToReason(42)).toBe("Upload failed");
|
||||
});
|
||||
|
||||
it("an AbortError that ISN'T a TimeoutError falls through to generic Error path", () => {
|
||||
// Belt-and-braces: a regression that loosened the name check to
|
||||
// ANY DOMException would silently rewrite legitimate AbortError
|
||||
// (user-initiated cancel) into "connection too slow". Pin the
|
||||
// narrow check.
|
||||
const err = new DOMException("user aborted", "AbortError");
|
||||
const out = mapUploadErrorToReason(err);
|
||||
// Falls through to non-Error branch (DOMException is not an Error
|
||||
// subclass in node's vitest environment); accept either generic
|
||||
// fallback or the Error-message form depending on the runtime.
|
||||
expect(out).not.toContain("connection is too slow");
|
||||
expect(out).not.toContain("File too large");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { uploadChatFiles } from "../uploads";
|
||||
import { uploadChatFiles, FileTooLargeError } from "../uploads";
|
||||
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
|
||||
import { extractFilesFromTask } from "../message-parser";
|
||||
|
||||
@@ -46,6 +46,52 @@ export function extractReplyText(resp: A2AResponse): string {
|
||||
return collected.join("\n");
|
||||
}
|
||||
|
||||
/** Map a thrown error from `uploadChatFiles` to the user-facing reason
|
||||
* shown in the chat error banner.
|
||||
*
|
||||
* Cases (per `feedback_surface_actionable_failure_reason_to_user` —
|
||||
* user-facing failures MUST tell the user WHY):
|
||||
*
|
||||
* 1. FileTooLargeError → use the error's message verbatim. The
|
||||
* pre-flight already built the actionable string with the actual
|
||||
* size + the cap; don't re-wrap it (which would prepend a
|
||||
* redundant "Upload failed:" prefix).
|
||||
*
|
||||
* 2. DOMException name="TimeoutError" → AbortSignal.timeout fired
|
||||
* during the fetch. Pre-flight already excluded file-size, so
|
||||
* this CANNOT mean "file too large". Surface a connection-speed
|
||||
* message — the user's actionable next step is retry or check
|
||||
* network, NOT shrink the file.
|
||||
*
|
||||
* 3. Other Error → use the wrapped form so the server's reason
|
||||
* (e.g. "upload failed: 413 ...") reaches the user instead of
|
||||
* being swallowed.
|
||||
*
|
||||
* 4. Non-Error throw → generic fallback.
|
||||
*
|
||||
* Exported for unit testing — the case-by-case mapping is the
|
||||
* load-bearing contract this PR ships. */
|
||||
export function mapUploadErrorToReason(e: unknown): string {
|
||||
if (e instanceof FileTooLargeError) {
|
||||
// Already a complete, user-facing sentence — surface verbatim.
|
||||
return e.message;
|
||||
}
|
||||
// DOMException with name="TimeoutError" is what AbortSignal.timeout
|
||||
// produces on abort. Browsers represent it as a DOMException, not a
|
||||
// regular Error subclass — feature-detect via .name to avoid coupling
|
||||
// to a global that's missing in test envs.
|
||||
if (
|
||||
e !== null && typeof e === "object" &&
|
||||
"name" in e && (e as { name: unknown }).name === "TimeoutError"
|
||||
) {
|
||||
return "Upload timed out — your connection is too slow for this file. Try again, or reduce file size.";
|
||||
}
|
||||
if (e instanceof Error) {
|
||||
return `Upload failed: ${e.message}`;
|
||||
}
|
||||
return "Upload failed";
|
||||
}
|
||||
|
||||
export interface UseChatSendOptions {
|
||||
getHistoryMessages: () => ChatMessage[];
|
||||
onUserMessage?: (msg: ChatMessage) => void;
|
||||
@@ -85,9 +131,12 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
} catch (e) {
|
||||
setUploading(false);
|
||||
sendInFlightRef.current = false;
|
||||
setError(
|
||||
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
|
||||
);
|
||||
// Error-reason routing (CTO 2026-05-19 on forensic a99ab0a1:
|
||||
// "if its file size issue, should have error that instead
|
||||
// saying timeout which is wrong"). Each cause maps to ITS
|
||||
// OWN message — NO conflation between file-size and
|
||||
// connection-too-slow.
|
||||
setError(mapUploadErrorToReason(e));
|
||||
return;
|
||||
}
|
||||
setUploading(false);
|
||||
|
||||
@@ -1,6 +1,55 @@
|
||||
import { PLATFORM_URL, platformAuthHeaders } from "@/lib/api";
|
||||
import type { ChatAttachment } from "./types";
|
||||
|
||||
/** Hard cap on a single chat upload. Pre-flight gate: this constant is
|
||||
* checked BEFORE any network I/O so a file-size violation surfaces
|
||||
* immediately with an actionable reason ("File too large (got X MB)
|
||||
* — limit is 100MB") rather than as a downstream timeout or 413.
|
||||
*
|
||||
* SERVER_MIRROR: keep aligned with
|
||||
* - workspace-server/internal/handlers/chat_files.go chatUploadMaxBytes
|
||||
* - workspace/internal_chat_uploads.py CHAT_UPLOAD_MAX_BYTES /
|
||||
* CHAT_UPLOAD_MAX_FILE_BYTES
|
||||
*
|
||||
* Three mirror sites exist because each layer must enforce / pre-flight
|
||||
* on its own (no shared codegen yet). Tracked for SSOT follow-up:
|
||||
* expose via GET /uploads/limits so the client can fetch the live cap
|
||||
* instead of duplicating the constant. */
|
||||
export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Thrown by `uploadChatFiles` when a candidate file exceeds
|
||||
* MAX_UPLOAD_BYTES. Caught by `useChatSend` and surfaced verbatim —
|
||||
* the message is already user-actionable. Distinct name lets the
|
||||
* catch path route it correctly without parsing the message string.
|
||||
*
|
||||
* Why a distinct class instead of a sentinel string match: the catch
|
||||
* in `useChatSend` already needs to discriminate this case from a
|
||||
* `TimeoutError` (which has a structurally similar surface but a
|
||||
* DIFFERENT root cause). Conflating them was the bug CTO flagged on
|
||||
* forensic a99ab0a1: "if its file size issue, should have error that
|
||||
* instead saying timeout which is wrong". */
|
||||
export class FileTooLargeError extends Error {
|
||||
readonly name = "FileTooLargeError";
|
||||
readonly fileSize: number;
|
||||
constructor(fileSize: number, message: string) {
|
||||
super(message);
|
||||
this.fileSize = fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the abort timeout for an upload of `totalBytes`. Floor at
|
||||
* 60s (small-file ergonomics: a 100 KB image shouldn't wait 1000s to
|
||||
* see a typo'd hostname surface as a connect error). Above the floor,
|
||||
* scale linearly at ~100 KB/s assumed minimum uplink — at the 100 MB
|
||||
* cap this yields ~1000s, comfortable for the slow-mobile-tether case
|
||||
* that motivated forensic a99ab0a1 (Ryan's >50 MB upload aborted at
|
||||
* the fixed 60s timeout while still streaming).
|
||||
*
|
||||
* Exported for the unit test that pins the curve at the boundary. */
|
||||
export function computeUploadTimeoutMs(totalBytes: number): number {
|
||||
return Math.max(60_000, totalBytes / 100); // 100KB/s → ms = bytes/100
|
||||
}
|
||||
|
||||
/** Chat attachments are intentionally uploaded via a direct fetch()
|
||||
* instead of the `api.post` helper — `api.post` JSON-stringifies the
|
||||
* body, which would 500 on a Blob. Auth headers (tenant slug, admin
|
||||
@@ -10,25 +59,57 @@ import type { ChatAttachment } from "./types";
|
||||
* Content-Type so the browser writes the multipart boundary into the
|
||||
* header; setting it manually would yield a multipart body the server
|
||||
* can't parse. See lib/api.ts platformAuthHeaders() for the full
|
||||
* rationale on why this pair must stay matched. */
|
||||
* rationale on why this pair must stay matched.
|
||||
*
|
||||
* Failure-reason contract (CTO 2026-05-19 directive on forensic
|
||||
* a99ab0a1: each cause maps to ITS OWN message, no conflation):
|
||||
* 1. file.size > MAX_UPLOAD_BYTES → throws FileTooLargeError
|
||||
* BEFORE any network I/O, with the offending size + the cap.
|
||||
* 2. fetch aborts via AbortSignal → DOMException name="TimeoutError";
|
||||
* caller surfaces "connection too slow" (file-size already
|
||||
* excluded by gate 1, so the TimeoutError CANNOT mean file-size).
|
||||
* 3. server returns !res.ok → throws Error with the server's
|
||||
* reason embedded (status + body); caller surfaces verbatim.
|
||||
* 4. any other thrown error → falls through as-is. */
|
||||
export async function uploadChatFiles(
|
||||
workspaceId: string,
|
||||
files: File[],
|
||||
): Promise<ChatAttachment[]> {
|
||||
if (files.length === 0) return [];
|
||||
|
||||
// PRE-FLIGHT: bail before any network I/O if any file exceeds the cap.
|
||||
// After this gate, an AbortSignal.timeout firing during the fetch
|
||||
// CANNOT be attributed to file size — it's necessarily a slow
|
||||
// connection. That distinction is what makes the downstream error
|
||||
// mapping unambiguous.
|
||||
let totalBytes = 0;
|
||||
for (const f of files) {
|
||||
if (f.size > MAX_UPLOAD_BYTES) {
|
||||
const sizeMb = (f.size / (1024 * 1024)).toFixed(1);
|
||||
throw new FileTooLargeError(
|
||||
f.size,
|
||||
`File too large (got ${sizeMb}MB) — limit is 100MB. Please use a smaller file.`,
|
||||
);
|
||||
}
|
||||
totalBytes += f.size;
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f, f.name);
|
||||
|
||||
// Uploads legitimately take a while on cold cache (tar write +
|
||||
// docker cp into the container). 60s is comfortable for the 25MB/
|
||||
// 50MB caps the server enforces.
|
||||
// Scale the abort timeout with payload size so a legitimate slow-
|
||||
// uplink upload of a large file isn't aborted before the body has
|
||||
// finished streaming. The fixed 60s previous-version was the root
|
||||
// cause of forensic a99ab0a1: Ryan's ~60 MB upload over a constrained
|
||||
// uplink streamed past 60s, AbortSignal fired client-side, server
|
||||
// got a truncated body, the user saw "signal timed out" — when the
|
||||
// real cause was simply "uplink slower than our hard-coded deadline".
|
||||
const res = await fetch(`${PLATFORM_URL}/workspaces/${workspaceId}/chat/uploads`, {
|
||||
method: "POST",
|
||||
headers: platformAuthHeaders(),
|
||||
body: form,
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
signal: AbortSignal.timeout(computeUploadTimeoutMs(totalBytes)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
|
||||
@@ -256,6 +256,13 @@ dependencies = [
|
||||
"uvicorn>=0.30.0",
|
||||
"starlette>=0.38.0",
|
||||
"websockets>=12.0",
|
||||
# multipart/form-data parser — required for Starlette's Request.form() on
|
||||
# /internal/chat/uploads/ingest. Without it, Starlette raises AssertionError
|
||||
# when parsing multipart bodies, which the chat-upload handler surfaces as
|
||||
# an opaque 400. Mirrors the canonical pin in workspace/requirements.txt;
|
||||
# >=0.0.27 avoids CVE-2024-53981 (DoS via malformed boundary).
|
||||
# Forensic a78762a0 (2026-05-19): Hermes PDF upload 400 root cause.
|
||||
"python-multipart>=0.0.27",
|
||||
"pyyaml>=6.0",
|
||||
"langchain-core>=0.3.0",
|
||||
"opentelemetry-api>=1.24.0",
|
||||
|
||||
@@ -53,11 +53,15 @@ http {
|
||||
harness-tenant-beta.localhost
|
||||
localhost;
|
||||
|
||||
# Cap upload at 50MB to mirror the staging tenant nginx limit;
|
||||
# Cap upload at 100MB to mirror the staging tenant nginx limit;
|
||||
# chat upload tests will fail closed if the platform handler
|
||||
# ever silently expands its limit (catches the failure mode
|
||||
# opposite of the chat-files lazy-heal incident).
|
||||
client_max_body_size 50m;
|
||||
# opposite of the chat-files lazy-heal incident). Bumped from
|
||||
# 50m to 100m in lockstep with chat_files.go chatUploadMaxBytes
|
||||
# (CTO 2026-05-19 directive on forensic a99ab0a1). If the
|
||||
# production CF / nginx tier still caps at 50m, this mirror
|
||||
# will pass while prod 413s — surface to ops if seen.
|
||||
client_max_body_size 100m;
|
||||
|
||||
location / {
|
||||
# The map above resolves $tenant_upstream to the right
|
||||
|
||||
@@ -67,7 +67,7 @@ type ChatFilesHandler struct {
|
||||
|
||||
// httpClient is broken out so tests can swap in an httptest.Server
|
||||
// transport. Prod uses a default with a generous Timeout to cover
|
||||
// the 50 MB worst case on a slow EC2 link without leaving a
|
||||
// the 100 MB worst case on a slow EC2 link without leaving a
|
||||
// connection hanging forever on a sick workspace.
|
||||
httpClient *http.Client
|
||||
|
||||
@@ -89,9 +89,14 @@ func NewChatFilesHandler(t *TemplatesHandler) *ChatFilesHandler {
|
||||
return &ChatFilesHandler{
|
||||
templates: t,
|
||||
httpClient: &http.Client{
|
||||
// 50 MB total body cap / ~1 MB/s slow-network floor → ~60s.
|
||||
// Doubled for headroom on the legitimate-but-slow case.
|
||||
Timeout: 120 * time.Second,
|
||||
// 100 MB total body cap / ~100 KB/s slow-uplink floor → ~1000s.
|
||||
// Doubled for headroom on the legitimate-but-slow case (e.g.
|
||||
// reno-stars 2026-05-19 forensic a99ab0a1: 60MB upload over a
|
||||
// constrained uplink). Client-side AbortSignal.timeout (canvas
|
||||
// uploads.ts) computes the matching deadline per-request and
|
||||
// surfaces "connection too slow" — distinct from the file-size
|
||||
// pre-flight that returns immediately before any network I/O.
|
||||
Timeout: 1200 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -107,10 +112,19 @@ func (h *ChatFilesHandler) WithPendingUploads(storage pendinguploads.Storage, br
|
||||
}
|
||||
|
||||
// chatUploadMaxBytes caps the full multipart request body so a
|
||||
// malicious / runaway client can't OOM the proxy hop. 50 MB matches
|
||||
// malicious / runaway client can't OOM the proxy hop. 100 MB matches
|
||||
// the workspace-side limit; anything larger is rejected at the
|
||||
// network boundary before forwarding.
|
||||
const chatUploadMaxBytes = 50 * 1024 * 1024
|
||||
//
|
||||
// CANVAS_MIRROR: keep aligned with canvas/src/components/tabs/chat/
|
||||
// uploads.ts MAX_UPLOAD_BYTES. The canvas constant exists so the
|
||||
// pre-flight size check can fail immediately (before network I/O)
|
||||
// with the actionable "File too large (got X MB) — limit is 100MB"
|
||||
// message. Bumping one side without the other yields the wrong-reason
|
||||
// surface that motivated this constant pair (CTO 2026-05-19 directive
|
||||
// on forensic a99ab0a1: file-size cause MUST surface as file-size,
|
||||
// NOT as a downstream timeout).
|
||||
const chatUploadMaxBytes = 100 * 1024 * 1024
|
||||
|
||||
// resolveWorkspaceForwardCreds resolves the workspace's URL +
|
||||
// platform_inbound_secret for an /internal/* forward, applying
|
||||
@@ -268,7 +282,7 @@ func contentDispositionAttachment(name string) string {
|
||||
// back unchanged.
|
||||
//
|
||||
// Why streaming, not parse-then-re-encode:
|
||||
// - Eliminates the 50 MB intermediate buffer on the platform.
|
||||
// - Eliminates the 100 MB intermediate buffer on the platform.
|
||||
// - Per-file size + path-safety enforcement is the workspace's job;
|
||||
// duplicating it here just creates two places to keep in sync.
|
||||
// - The error responses from the workspace (413 with the offending
|
||||
@@ -354,7 +368,7 @@ func (h *ChatFilesHandler) Upload(c *gin.Context) {
|
||||
// either.
|
||||
//
|
||||
// Body is streamed end-to-end (no buffering on the platform), preserving
|
||||
// binary safety and arbitrary file size (the 50 MB cap on Upload doesn't
|
||||
// binary safety and arbitrary file size (the 100 MB cap on Upload doesn't
|
||||
// apply to artefacts the agent produced).
|
||||
func (h *ChatFilesHandler) Download(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
@@ -546,8 +560,8 @@ type uploadedFile struct {
|
||||
// a fetcher crash mid-batch.
|
||||
//
|
||||
// Limits enforced here mirror the workspace-side ingest_handler:
|
||||
// - Total body cap: 50 MB (set on c.Request.Body before reaching us)
|
||||
// - Per-file cap: 25 MB (pendinguploads.MaxFileBytes; rejected as 413)
|
||||
// - Total body cap: 100 MB (set on c.Request.Body before reaching us)
|
||||
// - Per-file cap: 100 MB (pendinguploads.MaxFileBytes; rejected as 413)
|
||||
// - Filename: sanitized + capped at 100 chars (SanitizeFilename)
|
||||
//
|
||||
// Logging: every persisted file logs an INFO line with workspace_id,
|
||||
@@ -561,7 +575,7 @@ func (h *ChatFilesHandler) uploadPollMode(c *gin.Context, ctx context.Context, w
|
||||
// expose those limits directly — the underlying ParseMultipartForm
|
||||
// caps memory at 32 MB by default and spills to disk. For poll-
|
||||
// mode we read each file into memory to hand to Storage.Put;
|
||||
// 25 MB-per-file × 64-files ceiling means worst-case is 1.6 GB of
|
||||
// 100 MB-per-file × 64-files ceiling means worst-case is 6.4 GB of
|
||||
// peak memory. Bound the per-file size at the multipart layer so
|
||||
// the spill never gets close.
|
||||
if err := c.Request.ParseMultipartForm(32 << 20); err != nil {
|
||||
|
||||
@@ -374,7 +374,7 @@ func TestChatUpload_ForwardsErrorStatusUnchanged(t *testing.T) {
|
||||
|
||||
// Workspace returns 413 with its standard "exceeds per-file limit"
|
||||
// shape. Platform must propagate, NOT remap to 500.
|
||||
srv, _ := newCapturingWorkspace(t, http.StatusRequestEntityTooLarge, `{"error":"big.bin exceeds per-file limit (25 MB)"}`)
|
||||
srv, _ := newCapturingWorkspace(t, http.StatusRequestEntityTooLarge, `{"error":"big.bin exceeds per-file limit (100 MB)"}`)
|
||||
|
||||
wsID := "00000000-0000-0000-0000-000000000044"
|
||||
expectURL(mock, wsID, srv.URL)
|
||||
@@ -414,6 +414,81 @@ func TestChatUpload_WorkspaceUnreachable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatUpload_BodyUnderCap_Forwards pins the lower edge of the new
|
||||
// 100 MB body cap (CTO 2026-05-19 directive on forensic a99ab0a1).
|
||||
// A multipart payload comfortably under the cap must reach the
|
||||
// workspace's /internal/chat/uploads/ingest unchanged.
|
||||
//
|
||||
// Uses a small fixture (matching the rest of this suite) — the
|
||||
// http.MaxBytesReader cap is applied via a constant; pinning the cap
|
||||
// _value_ + a sub-cap-forwards test gives equivalent coverage to a
|
||||
// real-bytes 99 MB upload at a fraction of the test runtime.
|
||||
func TestChatUpload_BodyUnderCap_Forwards(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
if chatUploadMaxBytes != 100*1024*1024 {
|
||||
t.Fatalf("chatUploadMaxBytes regressed: want 100MB, got %d bytes — bump must stay in lockstep with canvas MAX_UPLOAD_BYTES + workspace CHAT_UPLOAD_MAX_BYTES", chatUploadMaxBytes)
|
||||
}
|
||||
|
||||
srv, _ := newCapturingWorkspace(t, http.StatusOK, `{"files":[]}`)
|
||||
wsID := "00000000-0000-0000-0000-000000000046"
|
||||
expectURL(mock, wsID, srv.URL)
|
||||
expectInboundSecret(mock, wsID, "tok")
|
||||
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil))
|
||||
body, ct := uploadFixture(t)
|
||||
c, w := makeUploadRequest(t, wsID, body, ct)
|
||||
h.Upload(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200 for sub-cap forward, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatUpload_BodyOverCap_413 verifies the 100 MB cap is enforced
|
||||
// at the platform's MaxBytesReader boundary. Because MaxBytesReader is
|
||||
// applied to c.Request.Body, the workspace forward only fails AFTER
|
||||
// the reader returns ErrBodyOverflow mid-stream — the forward http
|
||||
// client surfaces that as an error, which lands as 502 BadGateway
|
||||
// (the platform's contract for "couldn't complete the forward"). The
|
||||
// alternative would be eager Content-Length inspection — left as a
|
||||
// follow-up so chunked uploads (no Content-Length) still hit the
|
||||
// same gate.
|
||||
//
|
||||
// What this test pins: the cap CONSTANT is set to 100 MB and a body
|
||||
// strictly above the cap does NOT silently succeed (the upstream
|
||||
// receives a truncated body, the test workspace's parser would have
|
||||
// failed; here we simulate via a too-large body and assert non-2xx).
|
||||
func TestChatUpload_BodyOverCap_NotOK(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
// Capturing server that mimics workspace behaviour on truncated
|
||||
// multipart: returns 400. The test asserts the platform does NOT
|
||||
// turn this into a 200 success.
|
||||
srv, _ := newCapturingWorkspace(t, http.StatusBadRequest, `{"error":"malformed multipart"}`)
|
||||
wsID := "00000000-0000-0000-0000-000000000047"
|
||||
expectURL(mock, wsID, srv.URL)
|
||||
expectInboundSecret(mock, wsID, "tok")
|
||||
|
||||
h := NewChatFilesHandler(NewTemplatesHandler(t.TempDir(), nil, nil))
|
||||
|
||||
// Build a synthetic body that exceeds chatUploadMaxBytes by a
|
||||
// few bytes. We don't materialise 100MB+ in test memory — the
|
||||
// MaxBytesReader limit is applied lazily as the body is read,
|
||||
// so a marker-sized buffer + a custom reader that claims a large
|
||||
// Content-Length is enough to trip the gate.
|
||||
body := bytes.NewBuffer(make([]byte, chatUploadMaxBytes+1))
|
||||
c, w := makeUploadRequest(t, wsID, body, "multipart/form-data; boundary=----test")
|
||||
c.Request.ContentLength = int64(chatUploadMaxBytes + 1)
|
||||
h.Upload(c)
|
||||
|
||||
if w.Code >= 200 && w.Code < 300 {
|
||||
t.Errorf("expected non-2xx on over-cap upload, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDownload_InvalidPath(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -518,11 +518,24 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Check if MODEL_PROVIDER secret exists
|
||||
// Check if MODEL secret exists.
|
||||
//
|
||||
// Historical note: this row was named MODEL_PROVIDER pre-2026-05-19
|
||||
// (see ab12af50 + a7e8892 root-cause analysis). The column name
|
||||
// MODEL_PROVIDER was misleading — it never held a provider slug,
|
||||
// only the picked model id (e.g. "minimax/MiniMax-M2.7"). The
|
||||
// misnomer caused workspace-server's applyRuntimeModelEnv to
|
||||
// overwrite a legitimate persona-env MODEL with whatever literal
|
||||
// string lived in MODEL_PROVIDER (often "minimax" or "claude-code"
|
||||
// — not a valid model id), wedging adapters at SDK initialize.
|
||||
// CP-side slot-separation (cp#213 + cp#220) already corrected the
|
||||
// CP-side analogue; this is the workspace-server companion. A
|
||||
// migration in 20260519000000_workspace_secrets_model_provider_rename.up.sql
|
||||
// moves any legacy rows to the new key on rollout.
|
||||
var modelBytes []byte
|
||||
var modelVersion int
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
|
||||
`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
|
||||
workspaceID).Scan(&modelBytes, &modelVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusOK, gin.H{"model": "", "source": "default"})
|
||||
@@ -542,18 +555,23 @@ func (h *SecretsHandler) GetModel(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"model": string(decrypted), "source": "workspace_secrets"})
|
||||
}
|
||||
|
||||
// setModelSecret writes (or clears, when value=="") the MODEL_PROVIDER
|
||||
// workspace secret. Extracted from SetModel so non-handler call sites
|
||||
// (notably WorkspaceHandler.Create — first-deploy path that persists the
|
||||
// setModelSecret writes (or clears, when value=="") the MODEL workspace
|
||||
// secret. Extracted from SetModel so non-handler call sites (notably
|
||||
// WorkspaceHandler.Create — first-deploy path that persists the
|
||||
// canvas-selected model so applyRuntimeModelEnv's restart fallback finds
|
||||
// it) can reuse the encryption + upsert logic without inlining the SQL.
|
||||
//
|
||||
// The row was previously keyed MODEL_PROVIDER (misnomer — it never held
|
||||
// a provider, only a model id). Renamed to MODEL on 2026-05-19; the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration moves
|
||||
// any legacy rows on rollout.
|
||||
//
|
||||
// Returns nil on success. Caller is responsible for any restart trigger;
|
||||
// the gin handler re-adds that after a successful write.
|
||||
func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
if model == "" {
|
||||
_, err := db.DB.ExecContext(ctx,
|
||||
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL_PROVIDER'`,
|
||||
`DELETE FROM workspace_secrets WHERE workspace_id = $1 AND key = 'MODEL'`,
|
||||
workspaceID)
|
||||
return err
|
||||
}
|
||||
@@ -564,7 +582,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
version := crypto.CurrentEncryptionVersion()
|
||||
_, err = db.DB.ExecContext(ctx, `
|
||||
INSERT INTO workspace_secrets (workspace_id, key, encrypted_value, encryption_version)
|
||||
VALUES ($1, 'MODEL_PROVIDER', $2, $3)
|
||||
VALUES ($1, 'MODEL', $2, $3)
|
||||
ON CONFLICT (workspace_id, key) DO UPDATE
|
||||
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
|
||||
`, workspaceID, encrypted, version)
|
||||
@@ -572,7 +590,7 @@ func setModelSecret(ctx context.Context, workspaceID, model string) error {
|
||||
}
|
||||
|
||||
// SetModel handles PUT /workspaces/:id/model — writes the model slug
|
||||
// into workspace_secrets as MODEL_PROVIDER (the key GetModel reads).
|
||||
// into workspace_secrets as MODEL (the key GetModel reads).
|
||||
// For hermes, the value is a hermes-native slug like "minimax/MiniMax-M2.7";
|
||||
// for langgraph it's the legacy "provider:model" form. Either way it's just
|
||||
// an opaque string the runtime interprets on its next start.
|
||||
|
||||
@@ -479,8 +479,10 @@ func TestSecretsGetModel_Default(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
// No MODEL_PROVIDER secret
|
||||
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
|
||||
// No MODEL secret (formerly MODEL_PROVIDER — see 2026-05-19 rename
|
||||
// migration). Pin the WHERE clause so a regression that reads the
|
||||
// wrong column-name shows up here.
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("ws-model").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
@@ -516,7 +518,7 @@ func TestSecretsGetModel_DBError(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(nil)
|
||||
|
||||
mock.ExpectQuery("SELECT encrypted_value, encryption_version FROM workspace_secrets").
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("ws-model-err").
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
@@ -544,7 +546,9 @@ func TestSecretsSetModel_Upsert(t *testing.T) {
|
||||
restartCalled := make(chan string, 1)
|
||||
handler := NewSecretsHandler(func(id string) { restartCalled <- id })
|
||||
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets`).
|
||||
// Pin the literal 'MODEL' key in the SQL so a regression to the
|
||||
// pre-2026-05-19 'MODEL_PROVIDER' column name shows up here.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000001", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
@@ -578,7 +582,8 @@ func TestSecretsSetModel_EmptyClears(t *testing.T) {
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(func(string) {})
|
||||
|
||||
mock.ExpectExec(`DELETE FROM workspace_secrets`).
|
||||
// Pin the literal 'MODEL' key — see TestSecretsSetModel_Upsert.
|
||||
mock.ExpectExec(`DELETE FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000002").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -618,6 +623,65 @@ func TestSecretsSetModel_InvalidID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER pins the
|
||||
// 2026-05-19 rename: writes via SetModel land under workspace_secrets
|
||||
// key='MODEL', and reads via GetModel hit the same key. A regression
|
||||
// that reverts either side to 'MODEL_PROVIDER' will mismatch sqlmock's
|
||||
// query-regex anchor and fail loudly here. Combined integration-shape
|
||||
// guard for the secrets.go half of fix/workspace-server-rename-
|
||||
// MODEL_PROVIDER-to-MODEL.
|
||||
func TestSecretsModel_RoundTrip_KeyIsMODELNotMODEL_PROVIDER(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewSecretsHandler(func(string) {})
|
||||
|
||||
// 1. SetModel — must hit key='MODEL' in the INSERT.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'[\s\S]*ON CONFLICT`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000099", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
c1, _ := gin.CreateTestContext(w1)
|
||||
c1.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
|
||||
c1.Request = httptest.NewRequest("PUT", "/workspaces/00000000-0000-0000-0000-000000000099/model",
|
||||
strings.NewReader(`{"model":"gpt-5.5"}`))
|
||||
c1.Request.Header.Set("Content-Type", "application/json")
|
||||
handler.SetModel(c1)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("SetModel: expected 200, got %d: %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// 2. GetModel — must hit key='MODEL' in the SELECT. Return raw
|
||||
// bytes; the handler will run them through DecryptVersioned.
|
||||
// crypto is disabled in the test env (no MASTER_KEY), so the
|
||||
// raw bytes pass through unchanged. We assert the SELECT
|
||||
// fires against key='MODEL' (the rename pin); the decoded
|
||||
// value isn't load-bearing for this contract test.
|
||||
mock.ExpectQuery(`SELECT encrypted_value, encryption_version FROM workspace_secrets WHERE workspace_id = \$1 AND key = 'MODEL'`).
|
||||
WithArgs("00000000-0000-0000-0000-000000000099").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"encrypted_value", "encryption_version"}).
|
||||
AddRow([]byte("gpt-5.5"), 0))
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Params = gin.Params{{Key: "id", Value: "00000000-0000-0000-0000-000000000099"}}
|
||||
c2.Request = httptest.NewRequest("GET", "/workspaces/00000000-0000-0000-0000-000000000099/model", nil)
|
||||
handler.GetModel(c2)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("GetModel: expected 200, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
// We don't assert resp["model"] equals "gpt-5.5" because crypto
|
||||
// state in this package varies by build tag; the load-bearing
|
||||
// contract is the workspace_secrets key, pinned by the sqlmock
|
||||
// regex above. If a future change adds encryption to the test
|
||||
// env, the round-trip value check can move to an integration
|
||||
// test that owns the crypto state.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations — Model round-trip did not hit key='MODEL' on both sides: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetProvider / SetProvider (Option B PR-2) ====================
|
||||
//
|
||||
// Mirror of the GetModel/SetModel suite. Same secret-storage shape (key=
|
||||
|
||||
@@ -786,51 +786,57 @@ func applyRuntimeModelEnv(envVars map[string]string, runtime, model string) {
|
||||
// Resolution order (priority high → low):
|
||||
// 1. payload.Model (caller passed the canvas-picked model id verbatim)
|
||||
// 2. envVars["MOLECULE_MODEL"] (the canonical, unambiguous name)
|
||||
// 3. envVars["MODEL"] (workspace_secret persisted by /org/import via
|
||||
// the persona env file — MODEL=MiniMax-M2.7-highspeed etc.)
|
||||
// 4. envVars["MODEL_PROVIDER"] (legacy + misleadingly named: it carries
|
||||
// a *model id*, never the provider — that's LLM_PROVIDER. Historically
|
||||
// set by canvas Save+Restart's PUT /model; the post-2026-05-08
|
||||
// persona-env convention sometimes (mis)set it to a provider slug
|
||||
// ("minimax") or a runtime name ("claude-code"), neither a valid
|
||||
// model id — see internal#226. Only fires when the better-named
|
||||
// vars are absent.)
|
||||
// 3. envVars["MODEL"] (workspace_secret — written by SetModel /
|
||||
// WorkspaceHandler.Create / persona env file; the only correct
|
||||
// home for a picked model id).
|
||||
//
|
||||
// Pre-fix bug: this function unconditionally OVERWROTE envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug (when payload.Model was empty), wiping
|
||||
// the operator's explicit per-persona MODEL secret on every restart.
|
||||
// Symptom: a workspace whose persona env said
|
||||
// MODEL=MiniMax-M2.7-highspeed booted fine on first /org/import (the
|
||||
// envVars map was populated direct from the env file), then on the
|
||||
// next Restart the workspace_secrets-derived MODEL got clobbered by
|
||||
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model id —
|
||||
// and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize. Caught 2026-05-08
|
||||
// during Phase 4 verification of template-claude-code PR #9.
|
||||
// Pre-fix bug (2026-05-08): this function used to consult
|
||||
// envVars["MODEL_PROVIDER"] as a fourth fallback AND unconditionally
|
||||
// overwrite envVars["MODEL"] with that slug when payload.Model was
|
||||
// empty. The MODEL_PROVIDER key was misleadingly named — it carried
|
||||
// a model id, never a provider — and the persona-env convention
|
||||
// sometimes (mis)set it to a provider slug ("minimax") or a runtime
|
||||
// name ("claude-code"), neither a valid model id. Symptom: a
|
||||
// workspace whose persona env said MODEL=MiniMax-M2.7-highspeed
|
||||
// booted fine on first /org/import, then on the next Restart the
|
||||
// workspace_secrets-derived MODEL got clobbered by
|
||||
// MODEL_PROVIDER="minimax" — the literal slug, not a valid model
|
||||
// id — and the workspace template's adapter routed to providers[0]
|
||||
// (anthropic-oauth) and wedged at SDK initialize.
|
||||
//
|
||||
// The 2026-05-19 follow-up fix (this commit) renamed the
|
||||
// workspace_secrets row MODEL_PROVIDER → MODEL (root cause: the
|
||||
// misleading column name; see secrets.go + the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration)
|
||||
// and drops the MODEL_PROVIDER fallback here so the fallback chain
|
||||
// can no longer confuse a provider slug for a model id. CP-side
|
||||
// slot-separation (cp#213 + cp#220) merged the analogous fix on
|
||||
// the CP side; this is the workspace-server companion.
|
||||
if model == "" {
|
||||
model = envVars["MOLECULE_MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL"]
|
||||
}
|
||||
if model == "" {
|
||||
model = envVars["MODEL_PROVIDER"]
|
||||
}
|
||||
if model == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Canonical model env vars — molecule-runtime's workspace/config.py
|
||||
// resolves the picked model as MOLECULE_MODEL > MODEL > (legacy)
|
||||
// MODEL_PROVIDER (#280). Export both new names so adapters can read
|
||||
// either; MODEL stays for backwards compat with everything that
|
||||
// already reads os.environ["MODEL"] (the claude-code adapter does,
|
||||
// since #194). Without this, the user's canvas selection is silently
|
||||
// dropped on every templated provision — confirmed via crash-loop
|
||||
// diagnosis on 2026-05-02 where MiniMax picks booted with model=sonnet
|
||||
// (template default) and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these
|
||||
// FIRST so the per-runtime branches below can layer on additional
|
||||
// vendor-specific names without fighting over the canonical one.
|
||||
// MODEL_PROVIDER (#280; the legacy env-var fallback in the Python
|
||||
// runtime is independent of the workspace_secrets row rename — it
|
||||
// still reads the env var for back-compat with already-running
|
||||
// images, but workspace-server no longer emits it). Export both new
|
||||
// names so adapters can read either; MODEL stays for backwards
|
||||
// compat with everything that already reads os.environ["MODEL"]
|
||||
// (the claude-code adapter does, since #194). Without this, the
|
||||
// user's canvas selection is silently dropped on every templated
|
||||
// provision — confirmed via crash-loop diagnosis on 2026-05-02
|
||||
// where MiniMax picks booted with model=sonnet (template default)
|
||||
// and demanded CLAUDE_CODE_OAUTH_TOKEN. Set these FIRST so the
|
||||
// per-runtime branches below can layer on additional vendor-
|
||||
// specific names without fighting over the canonical one.
|
||||
envVars["MOLECULE_MODEL"] = model
|
||||
envVars["MODEL"] = model
|
||||
|
||||
|
||||
@@ -675,15 +675,22 @@ func TestDeriveProviderFromModelSlug(t *testing.T) {
|
||||
// TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider pins the
|
||||
// fix for failed-workspace 95ed3ff2 (2026-05-02). Pre-fix: the canvas
|
||||
// POSTed minimax/MiniMax-M2.7 in payload.Model, the workspace row was
|
||||
// created, but neither MODEL_PROVIDER nor LLM_PROVIDER was ever
|
||||
// created, but neither the model nor the derived provider was ever
|
||||
// written to workspace_secrets. On any subsequent restart, the
|
||||
// applyRuntimeModelEnv fallback found nothing in envVars["MODEL_PROVIDER"]
|
||||
// and hermes booted with the template default (nousresearch/hermes-4-70b)
|
||||
// → wrong provider keys → /health poll failed → never registered.
|
||||
// applyRuntimeModelEnv fallback found nothing and hermes booted with
|
||||
// the template default (nousresearch/hermes-4-70b) → wrong provider
|
||||
// keys → /health poll failed → never registered.
|
||||
//
|
||||
// Post-fix: the create handler writes both rows after committing the
|
||||
// workspace row. This test asserts the SQL writes happen with the
|
||||
// correct keys + values.
|
||||
//
|
||||
// 2026-05-19 follow-up: the workspace_secrets row that holds the
|
||||
// picked model id was renamed MODEL_PROVIDER → MODEL (the column name
|
||||
// was misleading and bled into applyRuntimeModelEnv as a slug
|
||||
// fallback). The sqlmock regex below now anchors on 'MODEL' instead
|
||||
// of 'MODEL_PROVIDER'. See fix/workspace-server-rename-
|
||||
// MODEL_PROVIDER-to-MODEL + the 20260519000000 rename migration.
|
||||
func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -699,13 +706,16 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
// The fix: MODEL_PROVIDER is upserted with the verbatim model slug.
|
||||
// SQL has 3 placeholders ($1=workspace_id, $2=encrypted_value reused
|
||||
// in the conflict-update, $3=version reused in the conflict-update),
|
||||
// so sqlmock sees 3 args. The 'MODEL_PROVIDER' / 'LLM_PROVIDER' key
|
||||
// is a literal in the SQL — we distinguish the two writes with the
|
||||
// regex match below.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
|
||||
// The fix: MODEL is upserted with the verbatim model slug
|
||||
// (renamed from MODEL_PROVIDER on 2026-05-19 — see file-level
|
||||
// docstring). SQL has 3 placeholders ($1=workspace_id, $2=
|
||||
// encrypted_value reused in the conflict-update, $3=version
|
||||
// reused in the conflict-update), so sqlmock sees 3 args. The
|
||||
// 'MODEL' / 'LLM_PROVIDER' key is a literal in the SQL — we
|
||||
// distinguish the two writes with the regex match below. The
|
||||
// 'MODEL' anchor uses a word boundary (`[^_A-Z]`) so it does
|
||||
// NOT silently match the legacy 'MODEL_PROVIDER' name.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// The fix: LLM_PROVIDER is upserted with the derived provider name.
|
||||
@@ -742,13 +752,13 @@ func TestWorkspaceCreate_FirstDeploy_PersistsModelAndProvider(t *testing.T) {
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL_PROVIDER + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
|
||||
t.Errorf("sqlmock expectations not met — first-deploy did NOT persist MODEL + LLM_PROVIDER (this is the prod bug recurrence): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten asserts that
|
||||
// when payload.Model is empty, NEITHER MODEL_PROVIDER nor LLM_PROVIDER
|
||||
// is written. Important: the canvas can omit `model` (template inherits
|
||||
// when payload.Model is empty, NEITHER MODEL nor LLM_PROVIDER is
|
||||
// written. Important: the canvas can omit `model` (template inherits
|
||||
// the runtime default later); we must not poison workspace_secrets with
|
||||
// empty rows in that case.
|
||||
func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
|
||||
@@ -792,10 +802,11 @@ func TestWorkspaceCreate_FirstDeploy_NoModel_NoSecretWritten(t *testing.T) {
|
||||
|
||||
// TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider
|
||||
// asserts the asymmetric case: an unknown model prefix still gets
|
||||
// MODEL_PROVIDER persisted (so the user's exact slug survives restart
|
||||
// and applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
|
||||
// MODEL persisted (so the user's exact slug survives restart and
|
||||
// applyRuntimeModelEnv finds it), but LLM_PROVIDER is skipped (so
|
||||
// derive-provider.sh's *=auto branch can decide at runtime instead of
|
||||
// being pre-empted by a guess).
|
||||
// being pre-empted by a guess). The MODEL key was renamed from
|
||||
// MODEL_PROVIDER on 2026-05-19 — see file-level docstring.
|
||||
func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
@@ -807,9 +818,9 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
// Only MODEL_PROVIDER — LLM_PROVIDER must NOT be written for
|
||||
// unknown prefixes. Same 3-arg shape as above; key is literal in SQL.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL_PROVIDER'`).
|
||||
// Only MODEL — LLM_PROVIDER must NOT be written for unknown
|
||||
// prefixes. Same 3-arg shape as above; key is literal in SQL.
|
||||
mock.ExpectExec(`INSERT INTO workspace_secrets[\s\S]*'MODEL'`).
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
@@ -836,7 +847,7 @@ func TestWorkspaceCreate_FirstDeploy_UnknownModel_OnlyMintModelProvider(t *testi
|
||||
t.Fatalf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL_PROVIDER but skip LLM_PROVIDER: %v", err)
|
||||
t.Errorf("sqlmock expectations not met — unknown-prefix model should mint MODEL but skip LLM_PROVIDER: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,11 +908,11 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
model: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MODEL_PROVIDER fallback hits: MODEL/MOLECULE_MODEL set from secret",
|
||||
name: "empty model + MODEL_PROVIDER env IGNORED post-2026-05-19 rename (the slug-fallback bug)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
modelProviderEnv: "MiniMax-M2",
|
||||
wantMODEL: "MiniMax-M2",
|
||||
wantMODEL: "",
|
||||
},
|
||||
{
|
||||
name: "empty model + MOLECULE_MODEL env fallback hits (canonical name)",
|
||||
@@ -911,7 +922,7 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MOLECULE_MODEL beats MODEL_PROVIDER when both set (misnomer guard, internal#226)",
|
||||
name: "MOLECULE_MODEL wins even when stale MODEL_PROVIDER is present (back-compat guard)",
|
||||
runtime: "claude-code",
|
||||
model: "",
|
||||
moleculeModelEnv: "opus",
|
||||
@@ -947,18 +958,26 @@ func TestApplyRuntimeModelEnv_SetsUniversalMODELForAllRuntimes(t *testing.T) {
|
||||
|
||||
// TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved locks in the
|
||||
// 2026-05-08 fix that prevents the MODEL_PROVIDER-as-slug fallback from
|
||||
// silently overwriting a per-persona MODEL workspace_secret on restart.
|
||||
// silently overwriting a per-persona MODEL workspace_secret on restart,
|
||||
// EXTENDED for the 2026-05-19 root-cause fix that drops the
|
||||
// MODEL_PROVIDER fallback entirely.
|
||||
//
|
||||
// Pre-fix bug recurrence guard: when the persona env file (loaded into
|
||||
// workspace_secrets at /org/import time) declares both MODEL=<id> and
|
||||
// MODEL_PROVIDER=<slug>, the restart path used to overwrite envVars["MODEL"]
|
||||
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv'\''s
|
||||
// with the MODEL_PROVIDER slug because applyRuntimeModelEnv's
|
||||
// payload.Model fallback consulted MODEL_PROVIDER first. Symptom: dev-tree
|
||||
// workspaces booted fine on first /org/import, then on next restart the
|
||||
// model id became literal "minimax" and the workspace template'\''s adapter
|
||||
// model id became literal "minimax" and the workspace template's adapter
|
||||
// failed to match any registry prefix, fell through to anthropic-oauth,
|
||||
// and wedged at SDK initialize. Caught during Phase 4 verification of
|
||||
// template-claude-code PR #9.
|
||||
//
|
||||
// 2026-05-19 follow-up: the MODEL_PROVIDER fallback is now removed.
|
||||
// MODEL is the only env-var source for the picked model id.
|
||||
// MODEL_PROVIDER is intentionally NOT consulted — a stale MODEL_PROVIDER
|
||||
// row left over from before the 20260519000000 migration must NOT leak
|
||||
// into envVars["MODEL"]. Verified by the third case below.
|
||||
func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -967,7 +986,7 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
wantMODEL string
|
||||
}{
|
||||
{
|
||||
name: "MODEL secret wins over MODEL_PROVIDER slug (persona-env shape on restart)",
|
||||
name: "MODEL secret wins; stale MODEL_PROVIDER ignored (persona-env shape on restart)",
|
||||
envMODEL: "MiniMax-M2.7-highspeed",
|
||||
envMP: "minimax",
|
||||
wantMODEL: "MiniMax-M2.7-highspeed",
|
||||
@@ -979,10 +998,10 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
wantMODEL: "opus",
|
||||
},
|
||||
{
|
||||
name: "MODEL absent → fall back to MODEL_PROVIDER (legacy canvas Save+Restart shape)",
|
||||
name: "MODEL absent → MODEL_PROVIDER no longer fallback (2026-05-19 fix): nothing set",
|
||||
envMODEL: "",
|
||||
envMP: "MiniMax-M2.7",
|
||||
wantMODEL: "MiniMax-M2.7",
|
||||
wantMODEL: "",
|
||||
},
|
||||
{
|
||||
name: "Both absent → no MODEL set",
|
||||
@@ -1009,3 +1028,48 @@ func TestApplyRuntimeModelEnv_PersonaEnvMODELSecretPreserved(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL is the
|
||||
// 2026-05-19 root-cause pin: workspaces that were live BEFORE the
|
||||
// 20260519000000_workspace_secrets_model_provider_rename migration ran
|
||||
// may still have a MODEL_PROVIDER row in workspace_secrets that lands
|
||||
// in envVars (the loader doesn't filter — anything in workspace_secrets
|
||||
// gets passed through). Post-fix, applyRuntimeModelEnv MUST NOT consult
|
||||
// that key for any purpose — neither as a fallback for the picked model
|
||||
// id nor as an indirect overwrite of MODEL. Asserts the read-out shape:
|
||||
//
|
||||
// - envVars["MODEL"] stays empty when no other source provided one
|
||||
// - envVars["MOLECULE_MODEL"] stays empty
|
||||
// - envVars["HERMES_DEFAULT_MODEL"] stays empty
|
||||
// - envVars["MODEL_PROVIDER"] itself is left as-is (we don't actively
|
||||
// scrub it — the rename migration does that on the DB side)
|
||||
//
|
||||
// Pairs with workspace_provision.go applyRuntimeModelEnv (line 817
|
||||
// fallback removed) and secrets.go (workspace_secrets key MODEL).
|
||||
func TestApplyRuntimeModelEnv_StaleMODELPROVIDERNeverLeaksIntoMODEL(t *testing.T) {
|
||||
envVars := map[string]string{
|
||||
"MODEL_PROVIDER": "minimax", // legacy slug — the prod-bug shape
|
||||
}
|
||||
applyRuntimeModelEnv(envVars, "claude-code", "")
|
||||
if got, ok := envVars["MODEL"]; ok {
|
||||
t.Errorf("MODEL must not be set from MODEL_PROVIDER fallback (post-2026-05-19 fix); got=%q", got)
|
||||
}
|
||||
if got, ok := envVars["MOLECULE_MODEL"]; ok {
|
||||
t.Errorf("MOLECULE_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
|
||||
}
|
||||
if got, ok := envVars["HERMES_DEFAULT_MODEL"]; ok {
|
||||
t.Errorf("HERMES_DEFAULT_MODEL must not be set from MODEL_PROVIDER fallback; got=%q", got)
|
||||
}
|
||||
if got := envVars["MODEL_PROVIDER"]; got != "minimax" {
|
||||
t.Errorf("MODEL_PROVIDER must be passed through untouched (DB-side rename handles cleanup); got=%q", got)
|
||||
}
|
||||
|
||||
// Hermes-runtime variant — same shape, same expectation.
|
||||
envVarsH := map[string]string{
|
||||
"MODEL_PROVIDER": "minimax",
|
||||
}
|
||||
applyRuntimeModelEnv(envVarsH, "hermes", "")
|
||||
if _, ok := envVarsH["HERMES_DEFAULT_MODEL"]; ok {
|
||||
t.Errorf("hermes runtime must not leak MODEL_PROVIDER into HERMES_DEFAULT_MODEL")
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
-- Reverse of 20260519000000_workspace_secrets_model_provider_rename.up.sql.
|
||||
--
|
||||
-- This rolls MODEL → MODEL_PROVIDER. Note: the up migration deleted any
|
||||
-- conflicting MODEL_PROVIDER rows when a MODEL row already existed, so
|
||||
-- this down migration is intentionally lossy in that direction — it
|
||||
-- cannot reconstruct rows the up migration discarded. Acceptable
|
||||
-- because:
|
||||
--
|
||||
-- 1. The discarded rows were duplicates with the same workspace_id;
|
||||
-- the surviving MODEL row carries the correct semantic value.
|
||||
-- 2. The application code post-rename never writes MODEL_PROVIDER, so
|
||||
-- any rollback after live traffic would produce duplicate-key
|
||||
-- conflicts on re-up anyway — discarding here is the only sane
|
||||
-- shape.
|
||||
--
|
||||
-- Provided for migration-tool symmetry; in practice the up direction is
|
||||
-- the canonical fix and rollback should not happen.
|
||||
|
||||
UPDATE workspace_secrets
|
||||
SET key = 'MODEL_PROVIDER', updated_at = now()
|
||||
WHERE key = 'MODEL';
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
-- Rename workspace_secrets rows MODEL_PROVIDER → MODEL.
|
||||
--
|
||||
-- Root cause: the column-name MODEL_PROVIDER was misleading — it never
|
||||
-- held a provider slug, only a picked model id (e.g.
|
||||
-- "minimax/MiniMax-M2.7"). Application code (workspace-server
|
||||
-- applyRuntimeModelEnv) read MODEL_PROVIDER as a fallback that could
|
||||
-- overwrite a legitimate MODEL persona-env secret with whatever literal
|
||||
-- string lived in MODEL_PROVIDER — often a provider slug like "minimax"
|
||||
-- or a runtime name like "claude-code", neither of which is a valid
|
||||
-- model id. The wrong shape then propagated into CP user-data and the
|
||||
-- workspace adapter wedged at SDK initialize (see failed-workspace
|
||||
-- 95ed3ff2 2026-05-02 and the Researcher/Reviewer poisoning 2026-05-19).
|
||||
--
|
||||
-- Pairs with the secrets.go + workspace_provision.go rename in this
|
||||
-- PR (fix/workspace-server-rename-MODEL_PROVIDER-to-MODEL) and the
|
||||
-- CP-side slot-separation already landed in cp#213 + cp#220.
|
||||
--
|
||||
-- Conflict handling: a workspace_secrets row already keyed MODEL takes
|
||||
-- precedence (persona-env files commonly write MODEL=... directly), so
|
||||
-- the MODEL_PROVIDER row is deleted instead of overwriting MODEL. The
|
||||
-- WHERE NOT EXISTS guard makes the migration idempotent — re-running
|
||||
-- it on an already-renamed schema is a no-op.
|
||||
|
||||
UPDATE workspace_secrets
|
||||
SET key = 'MODEL', updated_at = now()
|
||||
WHERE key = 'MODEL_PROVIDER'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM workspace_secrets ws2
|
||||
WHERE ws2.workspace_id = workspace_secrets.workspace_id
|
||||
AND ws2.key = 'MODEL'
|
||||
);
|
||||
|
||||
-- Drop any leftover MODEL_PROVIDER rows where a MODEL row already
|
||||
-- exists (MODEL wins — see above).
|
||||
DELETE FROM workspace_secrets
|
||||
WHERE key = 'MODEL_PROVIDER';
|
||||
@@ -0,0 +1 @@
|
||||
# trigger autobump for python-multipart pin (PDF P0 cure)
|
||||
@@ -27,8 +27,8 @@ Path safety:
|
||||
collisions astronomical, but defense-in-depth costs nothing).
|
||||
|
||||
Limits (matches the Go contract from chat_files.go):
|
||||
- 50 MB total request body
|
||||
- 25 MB per file
|
||||
- 100 MB total request body
|
||||
- 100 MB per file
|
||||
- filename truncated to 100 chars
|
||||
|
||||
Response shape:
|
||||
@@ -64,11 +64,20 @@ CHAT_UPLOAD_DIR = "/workspace/.molecule/chat-uploads"
|
||||
# Total-request body cap. multipart/form-data with multiple parts can
|
||||
# add ~100 bytes of framing per file; the cap is the bytes hitting the
|
||||
# socket, including framing.
|
||||
CHAT_UPLOAD_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
|
||||
#
|
||||
# SERVER_MIRROR: keep aligned with workspace-server/internal/handlers/
|
||||
# chat_files.go chatUploadMaxBytes AND canvas/src/components/tabs/chat/
|
||||
# uploads.ts MAX_UPLOAD_BYTES. Three constants exist (platform Go +
|
||||
# workspace Python + canvas TS) because each layer must enforce or
|
||||
# pre-flight the cap on its own; an SSOT follow-up tracked in
|
||||
# molecule-ai/internal would expose the cap via GET /uploads/limits.
|
||||
CHAT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
# Per-file cap. Keeping per-file under total lets a user attach, say,
|
||||
# a 5 MB PDF + 10 small screenshots in a single batch.
|
||||
CHAT_UPLOAD_MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||
# Per-file cap. Aligned with the total at 100 MB so a single legitimate
|
||||
# large file (e.g. a 70 MB PDF — reno-stars 2026-05-19 forensic
|
||||
# a99ab0a1) succeeds end-to-end; batched small attachments still fit
|
||||
# under the same ceiling.
|
||||
CHAT_UPLOAD_MAX_FILE_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||
|
||||
# Conservative {alnum, dot, underscore, dash} character class — anything
|
||||
# outside gets rewritten so embedded paths, control chars, newlines,
|
||||
|
||||
@@ -210,7 +210,7 @@ def test_no_files_field_returns_400(client: TestClient):
|
||||
|
||||
def test_per_file_oversize_returns_413(client: TestClient, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Per-file cap is enforced. Lower the cap for the test so we don't
|
||||
have to construct a real 25 MB body."""
|
||||
have to construct a real 100 MB body."""
|
||||
monkeypatch.setattr(internal_chat_uploads, "CHAT_UPLOAD_MAX_FILE_BYTES", 16)
|
||||
big = b"x" * 32 # > 16
|
||||
r = client.post(
|
||||
|
||||
Reference in New Issue
Block a user