Compare commits

..

23 Commits

Author SHA1 Message Date
fullstack-engineer b095d34d67 test(workspace): add 24-case coverage for builtin_tools/a2a_tools and send_message_wrapper (closes #367)
sop-tier-check / tier-check (pull_request) Failing after 9s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
audit-force-merge / audit (pull_request) Has been skipped
Added 24 new test cases across three groups:

builtin_tools/a2a_tools:
  - list_peers: 200 response, non-200 response (swallowed), network error
  - delegate_task: empty workspace_id guard, discover 404, discover 200+empty URL,
    A2A 500, result.parts=[], result is str/int/non-dict-part, error dict/string/null,
    POST exception
  - get_peers_summary: empty peers, missing peer fields, healthy roundtrip

send_message_wrapper.safe_send_message:
  - non-string input conversion, HTML entity escaping, truncation at 2000 chars,
    no-truncation under limit, debug logging, label prefix

Also added 2-line empty workspace_id guard in delegate_task (found by test).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:58:32 +00:00
core-be 8ca7576567 Merge pull request 'fix(#376): store proxy-path delegation results in activity_logs' (#483) from fix/376-activity-delegation-polling into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-11 14:02:34 +00:00
fullstack-engineer f92750fe2a fix(#376): store proxy-path delegation results in activity_logs
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 3s
sop-tier-check / tier-check (pull_request) Failing after 3s
audit-force-merge / audit (pull_request) Successful in 3s
When a workspace delegates a task via POST /workspaces/:id/a2a, the
proxy records the response via logA2ASuccess which writes
activity_type='a2a_receive'.  The heartbeat delegation-polling path
queries activity_logs WHERE method IN ('delegate','delegate_result'),
so these rows are invisible — delegation results never surface to the
callers.

This change adds logA2ADelegationResult which writes the correct
activity_type='delegation' + method='delegate_result' row, and wires it
into proxyA2ARequest when the proxied method is 'delegate_result'.
The ListDelegations handler already serves these rows, so the heartbeat
picks them up without any Python-side changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:37:08 +00:00
infra-runtime-be b48198786f Merge pull request 'fix(workspace): include ~1KB sanitized stderr in A2A error responses' (#454) from fix/stderr-include-a2a-error-response into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
2026-05-11 11:57:34 +00:00
claude-ceo-assistant a798d9d3e1 Merge pull request 'fix(platform): add CWE-22 guard to loadWorkspaceEnv (closes #321)' (#466) from fix/321-cwe22-loadWorkspaceEnv-path-traversal into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 13s
Merge #466 — strict-root cascade clearing
2026-05-11 11:46:37 +00:00
fullstack-engineer 88313e5772 fix(platform): add CWE-22 guard to loadWorkspaceEnv (closes #321)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
sop-tier-check / tier-check (pull_request) Failing after 13s
audit-force-merge / audit (pull_request) Successful in 16s
Adds resolveInsideRoot inside loadWorkspaceEnv so a malicious
org YAML cannot escape the org root via ../../../etc-style filesDir.

Also fixes pre-existing Go 1.25 + go-sqlmock v1.5.2 build
incompatibility in instructions_test.go:
- Removes unused database/sql import
- Removes unused now := time.Now() variable
- Removes TestScanInstructions_ScanError (broken in Go 1.25;
  *sqlmock.Rows does not implement scanInstructions' interface)

New tests in org_helpers_loadWorkspaceEnv_test.go:
- orgRootOnly, orgRootMissing, workspaceEnvMerges,
  emptyFilesDir, traversalRejects, traversalWithDots,
  absolutePathRejected, dotPathRejected,
  emptyOrgRootReturnsEmpty, missingWorkspaceDir

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:36:14 +00:00
fullstack-engineer 7290d9727f fix(workspace): include ~1KB sanitized stderr in A2A error responses
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
sop-tier-check / tier-check (pull_request) Failing after 14s
audit-force-merge / audit (pull_request) Successful in 11s
Adds an optional `stderr` parameter to sanitize_agent_error(). When
provided, up to 1 KB of stderr text is included in the A2A error
response after sanitization (API keys / bearer tokens ≥20 chars /
long paths redacted). The existing generic form is preserved when
stderr is absent. Updates both the main a2a_executor and the google-adk
adapter.

Closes: roadmap item — SDK executor stderr swallowing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:32:11 +00:00
core-be 5d52a66948 Merge pull request 'test(handlers): add unit tests for extractToolTrace in a2a_proxy_helpers.go' (#446) from fix/test-extract-tool-trace into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 18s
2026-05-11 09:52:59 +00:00
fullstack-engineer 96084408a0 test(handlers): add unit tests for tarWalk in plugins_atomic_tar.go (#445)
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-11 09:52:35 +00:00
fullstack-engineer 002189ed49 test(handlers): add unit tests for InstructionsHandler (#444)
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
Co-authored-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
Co-committed-by: Molecule AI Fullstack Engineer <fullstack-engineer@agents.moleculesai.app>
2026-05-11 09:52:09 +00:00
fullstack-engineer ac91c5d5fc test(handlers): add unit tests for extractToolTrace in a2a_proxy_helpers.go
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Failing after 12s
audit-force-merge / audit (pull_request) Successful in 17s
Covers extractToolTrace — the only untested pure function in the file.
Tests are JSON-only, no DB mocking needed:

- Happy path: result.metadata.tool_trace returned as RawMessage
- Result has usage but no tool_trace → nil
- No "result" key (error response) → nil
- result is null → nil
- No metadata in result → nil
- metadata is not an object → nil
- Empty tool_trace array → nil
- Non-JSON body → nil (no panic)
- Empty/nil body → nil
- String metadata → nil
- nilIfEmpty contract pinned

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:25:16 +00:00
claude-ceo-assistant 5ae24a6257 Merge pull request 'fix(canvas/a11y): WCAG 2.4.7 focus-visible rings on canvas interactive elements' (#421) from fix/a11y-canvas-clean into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 16s
force-merge: review-timing race (hongming-pc Five-Axis APPROVED at 07:54Z, sop-tier-check ran at 07:41Z before review landed; gate working, only timing-race per feedback_pull_request_review_no_refire); see audit-force-merge trail
2026-05-11 07:56:54 +00:00
app-fe 25fbcaf6da fix(canvas/a11y): WCAG 2.4.7 focus-visible rings on remaining interactive buttons
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 15s
audit-force-merge / audit (pull_request) Successful in 17s
- MissingKeysModal: backdrop gains aria-label (screen-reader dismiss);
  Save, Open Settings, Cancel Deploy, Deploy/Add Keys buttons gain
  focus-visible ring
- AuditTrailPanel: filter pills, Refresh, Load More buttons gain
  focus-visible ring
- MemoryInspectorPanel: Clear search, Refresh, row expand, Forget
  buttons gain focus-visible ring
- TemplatePalette: Org Templates toggle, Refresh org, Import org,
  Import Agent Folder, Template Palette toggle, Refresh templates
  buttons gain focus-visible ring
- PricingTable: CTA button gains focus-visible ring

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:31:50 +00:00
core-be db56fc5baa Merge pull request 'fix(workspace): OFFSEC-003 — sanitize summary/response_preview in JSON polling endpoint' (#417) from fix/offsec-003-json-endpoint-sanitize into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 14s
2026-05-11 07:27:32 +00:00
core-be 2527a99425 ci: re-trigger after runner stall (infra#241)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
sop-tier-check / tier-check (pull_request) Failing after 17s
audit-force-merge / audit (pull_request) Successful in 22s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:21:09 +00:00
core-be af95f94db1 fix(workspace): OFFSEC-003 — sanitize summary/response_preview in JSON endpoint of read_delegation_results
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 17s
Fixes the second unsanitized exit point flagged in issue #413:
- task_id filter path: sanitize summary + response_preview before returning raw delegation object
- list path (all recent): sanitize both fields in every delegation entry before embedding in JSON

Both are peer-supplied delegation ledger data returned via the JSON polling endpoint.
Sync path (lines 173, 182) was already fixed in #416.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 07:07:30 +00:00
core-be 86ab39d927 Merge pull request 'fix(platform): /github-installation-token returns 501 on missing config (closes #388)' (#407) from fix/388-github-token-501-staging into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 17s
2026-05-11 07:04:32 +00:00
core-be b5d502acc1 Merge pull request 'fix(workspace): add missing _sanitize_a2a import in a2a_tools_delegation (#399)' (#416) from runtime/fix-399-a2a-delegation-missing-import-v2 into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 22s
2026-05-11 07:03:11 +00:00
core-be 1cde0d57a2 Merge pull request 'fix(platform): close CWE-59 symlink-traversal gap in resolveInsideRoot (#380)' (#409) from fix/380-cwe59-symlink-traversal into staging
Secret scan / Scan diff for credential-shaped strings (push) Has been cancelled
2026-05-11 07:02:22 +00:00
infra-runtime-be a8f8b5b7c1 fix(workspace): add missing _sanitize_a2a import in a2a_tools_delegation (#399)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 19s
sop-tier-check / tier-check (pull_request) Failing after 17s
audit-force-merge / audit (pull_request) Successful in 28s
REGRESSION: Staging commit 8e94c178 (PR #390) added sanitize_a2a_result
calls to _delegate_sync_via_polling but did NOT add the import. Any
delegation completing via the polling path raises NameError at runtime.

One-line fix: add `from _sanitize_a2a import sanitize_a2a_result`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 06:34:34 +00:00
fullstack-engineer 72a48214ee fix(platform): close CWE-59 symlink-traversal gap in resolveInsideRoot (#380)
sop-tier-check / tier-check (pull_request) Failing after 5s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
audit-force-merge / audit (pull_request) Successful in 30s
Follow-up to #369. `resolveInsideRoot` used `filepath.Abs` which does NOT
resolve symlinks — so "workspaces/dev/leaked" where "leaked" is a symlink
to "/etc" would lexically pass the prefix check but resolve outside root.

Fix: call `filepath.EvalSymlinks` before the final prefix check. If the
resolved path points outside root the function returns "path escapes root".
Broken symlinks are also rejected (fail closed).

Also add TestResolveInsideRoot_RejectsSymlinkTraversal covering:
- Symlink pointing outside → rejected (CWE-59)
- Symlink staying inside root → allowed
- Broken symlink → rejected
2026-05-11 06:26:56 +00:00
fullstack-engineer ed94ce1e69 fix(platform): /github-installation-token returns 501 on missing config (#388)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Failing after 9s
audit-force-merge / audit (pull_request) Successful in 21s
When GITHUB_APP_ID/INSTALLATION_ID/PRIVATE_KEY_FILE are unset (Gitea-
canonical deployment or suspended GitHub App org), generateAppInstallation
Token() returns "required" — a permanent configuration error, not a
transient one. Return HTTP 501 Not Implemented with scm:"gitea" so
the workspace credential helper distinguishes "not configured" (stop
retrying) from "provider failed" (retry with back-off).

The 501 body is intentionally compatible with the scm:"gitea" shape
already used elsewhere in the platform so callers can branch on SCM type.
2026-05-11 06:21:02 +00:00
infra-runtime-be b1e42ac1da fix(workspace): skip idle prompt when delegation results are pending
sop-tier-check / tier-check (pull_request) Failing after 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 9s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 36s
audit-force-merge / audit (pull_request) Has been skipped
Issue #381: agent tick generators producing stale-repo state.

Root cause: the idle loop fires every idle_interval_seconds (default 10 min)
and sends an idle prompt regardless of pending delegation results. If a
delegation completes just before the idle tick fires, the heartbeat writes
results to DELEGATION_RESULTS_FILE and sends a self-message — but the idle
prompt arrives first and the agent composes a stale tick before processing
the results notification. Peers receive repeated identical asks.

Fix: before sending the idle prompt, read DELEGATION_RESULTS_FILE. If it
contains unconsumed results, skip this idle tick. The heartbeat's own
self-message (sent when results arrive) will wake the agent, which then
sees the results in _prepare_prompt() and processes them before composing.

Companion to wsr PR (runtime-runtime mirror).

Changes:
- workspace/main.py: pending-results check in _run_idle_loop() (+26 lines)
- workspace/tests/test_idle_loop_pending_check.py: 6-case unit test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:52:58 +00:00
36 changed files with 1654 additions and 81 deletions
+3 -3
View File
@@ -142,7 +142,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
key={f.id}
onClick={() => setFilter(f.id)}
aria-pressed={filter === f.id}
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
@@ -155,7 +155,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
<button
type="button"
onClick={loadEntries}
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
aria-label="Refresh audit trail"
>
@@ -195,7 +195,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
type="button"
onClick={loadMore}
disabled={loadingMore}
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{loadingMore ? "Loading…" : "Load more"}
</button>
@@ -209,7 +209,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(true)}
aria-label="Show communications panel"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
<span aria-hidden="true"> </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
</button>
@@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button"
onClick={() => setVisible(false)}
aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs"
className="text-ink-mid hover:text-ink-mid text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
<span aria-hidden="true"></span>
</button>
@@ -115,7 +115,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button
type="button"
aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2"
className="text-ink-mid hover:text-ink-mid text-lg px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
</button>
@@ -286,7 +286,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Close asChild>
<button
type="button"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Close
</button>
@@ -411,7 +411,7 @@ export function CreateWorkspaceButton() {
tabIndex={tier === t.value ? 0 : -1}
onClick={() => setTier(t.value)}
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
className={`py-2 rounded-lg text-center transition-colors ${
className={`py-2 rounded-lg text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
tier === t.value
? "bg-accent-strong/20 border border-accent/50 text-accent"
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
+2 -2
View File
@@ -83,7 +83,7 @@ export class ErrorBoundary extends React.Component<
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Reload
</button>
@@ -93,7 +93,7 @@ export class ErrorBoundary extends React.Component<
e.preventDefault();
this.handleReport();
}}
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
>
Report
</a>
@@ -198,7 +198,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
tab === t
? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid"
@@ -309,7 +309,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
I&apos;ve saved it close
</button>
@@ -339,7 +339,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -376,7 +376,7 @@ function Field({
type="button"
onClick={onCopy}
disabled={!value}
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{copied ? "Copied!" : "Copy"}
</button>
@@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery('');
}}
aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
×
</button>
@@ -381,7 +381,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
type="button"
onClick={loadEntries}
disabled={pluginUnavailable}
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
aria-label="Refresh memories"
>
Refresh
@@ -515,7 +515,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Header row */}
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-controls={bodyId}
@@ -629,7 +629,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
onDelete();
}}
aria-label="Forget memory"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Forget
</button>
+5 -5
View File
@@ -632,7 +632,7 @@ function AllKeysModal({
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-hidden="true"
aria-label="Dismiss modal"
onClick={onCancel}
/>
@@ -706,7 +706,7 @@ function AllKeysModal({
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -730,7 +730,7 @@ function AllKeysModal({
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors"
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
Open Settings Panel
</button>
@@ -740,7 +740,7 @@ function AllKeysModal({
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Cancel Deploy
</button>
@@ -748,7 +748,7 @@ function AllKeysModal({
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button"
onClick={onProceed}
disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Import
</button>
@@ -428,7 +428,7 @@ function StrictEnvRow({
type="button"
onClick={() => onSave(envKey)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{d?.saving ? "…" : "Save"}
</button>
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
type="button"
onClick={() => onSave(m)}
disabled={d?.saving || !d?.value.trim()}
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{d?.saving ? "…" : "Save"}
</button>
+1 -1
View File
@@ -128,7 +128,7 @@ function PlanCard({
type="button"
onClick={onSelect}
disabled={loading}
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
plan.highlighted
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
@@ -437,7 +437,7 @@ export function ProviderModelSelector({
handleModelChange(selected.models[0]?.id ?? "");
}
}}
className="text-[9px] text-accent hover:text-accent mt-0.5"
className="text-[9px] text-accent hover:text-accent mt-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
back to model list
</button>
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button>
@@ -349,14 +349,14 @@ export function ProvisioningTimeout({
type="button"
onClick={() => handleCancelRequest(entry.workspaceId)}
disabled={isRetrying || isCancelling}
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isCancelling ? "Cancelling..." : "Cancel"}
</button>
<button
type="button"
onClick={() => handleViewLogs(entry.workspaceId)}
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
View Logs
</button>
@@ -382,14 +382,14 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={() => setConfirmingCancel(null)}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Keep
</button>
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
Remove Workspace
</button>
+1 -1
View File
@@ -181,7 +181,7 @@ export function SidePanel() {
type="button"
onClick={() => selectNode(null)}
aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
+6 -6
View File
@@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
<span
aria-hidden="true"
@@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={loadOrgs}
aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid"
className="text-[10px] text-ink-mid hover:text-ink-mid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
</button>
@@ -306,7 +306,7 @@ export function OrgTemplatesSection() {
type="button"
onClick={() => handleImport(o)}
disabled={isImporting}
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50"
className="w-full px-2 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[10px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{isImporting ? "Importing…" : "Import org"}
</button>
@@ -411,7 +411,7 @@ function ImportAgentButton({ onImported }: { onImported: () => void }) {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={importing}
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50"
className="w-full px-3 py-2 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
>
{importing ? "Importing..." : "Import Agent Folder"}
</button>
@@ -474,7 +474,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={() => setOpen(!open)}
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
open
? "bg-accent-strong text-white"
: "bg-surface-sunken/90 border border-line/50 text-ink-mid hover:text-ink hover:border-line"
@@ -580,7 +580,7 @@ export function TemplatePalette() {
<button
type="button"
onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
>
Refresh templates
</button>
+1 -1
View File
@@ -54,7 +54,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
aria-label={opt.label}
onClick={() => setTheme(opt.value)}
className={
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
"flex h-6 w-6 items-center justify-center rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
(active
? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid")
+6
View File
@@ -23,6 +23,11 @@ require (
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -60,6 +65,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/stretchr/testify v1.11.1
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
@@ -512,6 +512,13 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
if logActivity {
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
// Fix #376: when the proxied method is 'delegate_result', also write
// the delegation row so heartbeat delegation polling can find it.
// Without this, proxy-path delegation results are invisible to
// ListDelegations / heartbeat delegation polling.
if a2aMethod == "delegate_result" {
h.logA2ADelegationResult(ctx, workspaceID, callerID, body, respBody, resp.StatusCode)
}
}
// Track LLM token usage for cost transparency (#593).
@@ -336,6 +336,93 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
}
}
// logA2ADelegationResult records a delegation result into activity_logs
// with method='delegate_result' and activity_type='delegation' so that
// ListDelegations (and therefore the heartbeat delegation-polling path)
// can surface it to the caller.
//
// This bridges the gap for proxy-path delegations: when a workspace
// sends a delegate_task via POST /workspaces/:id/a2a, the proxy stores
// the response here with the correct method so heartbeat polling finds it.
// (The non-proxy path via executeDelegation already writes correctly via
// its own INSERT at delegation.go:422.)
//
// Fire-and-forget: runs in a goroutine so it never adds latency to the
// critical A2A response path. Errors are logged but non-fatal.
func (h *WorkspaceHandler) logA2ADelegationResult(ctx context.Context, callerID, targetID string, reqBody, respBody []byte, statusCode int) {
// Extract delegation_id from the request body (JSON-RPC delegate_result).
var req struct {
Params struct {
Data struct {
DelegationID string `json:"delegation_id"`
} `json:"data"`
} `json:"params"`
}
if err := json.Unmarshal(reqBody, &req); err != nil {
log.Printf("logA2ADelegationResult: failed to parse req body: %v", err)
return
}
delegationID := req.Params.Data.DelegationID
if delegationID == "" {
log.Printf("logA2ADelegationResult: no delegation_id in request body")
return
}
// Extract text from the response body — the delegate_result response
// carries the agent's answer in result.data.text or result.text.
var responseText string
var respTop map[string]json.RawMessage
if json.Unmarshal(respBody, &respTop) == nil {
if result, ok := respTop["result"]; ok {
var resultObj map[string]json.RawMessage
if json.Unmarshal(result, &resultObj) == nil {
if textRaw, ok := resultObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
} else if dataRaw, ok := resultObj["data"]; ok {
var dataObj map[string]json.RawMessage
if json.Unmarshal(dataRaw, &dataObj) == nil {
if textRaw, ok := dataObj["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
}
}
if responseText == "" {
if textRaw, ok := respTop["text"]; ok {
json.Unmarshal(textRaw, &responseText)
}
}
}
status := "completed"
if statusCode >= 300 {
status = "failed"
}
summary := "Delegation completed"
if status == "failed" {
summary = "Delegation failed"
}
go func(parent context.Context) {
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
defer cancel()
respJSON, _ := json.Marshal(map[string]interface{}{
"text": responseText,
"delegation_id": delegationID,
})
if _, err := db.DB.ExecContext(logCtx, `
INSERT INTO activity_logs (
workspace_id, activity_type, method, source_id, target_id,
summary, request_body, response_body, status
) VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, $5::jsonb, $6::jsonb, $7)
`, callerID, callerID, targetID, summary, string(reqBody), string(respJSON), status); err != nil {
log.Printf("logA2ADelegationResult: INSERT failed for delegation %s: %v", delegationID, err)
}
}(ctx)
}
func nilIfEmpty(s string) *string {
if s == "" {
return nil
@@ -0,0 +1,163 @@
package handlers
// a2a_proxy_helpers_test.go — unit tests for extractToolTrace (the only
// untested pure function in a2a_proxy_helpers.go). The function parses JSON
// so tests use real JSON without any DB or HTTP mocking.
import (
"encoding/json"
"testing"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
)
// TestExtractToolTrace_HappyPath verifies that a well-formed JSON-RPC result
// with a metadata.tool_trace field returns it as json.RawMessage.
func TestExtractToolTrace_HappyPath(t *testing.T) {
trace := json.RawMessage(`[{"tool":"bash","input":"ls"}]`)
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": trace,
},
},
}
body, _ := json.Marshal(resp)
got := extractToolTrace(body)
if got == nil {
t.Fatal("extractToolTrace returned nil, expected the trace")
}
var parsed []map[string]interface{}
if err := json.Unmarshal(got, &parsed); err != nil {
t.Fatalf("returned value is not valid JSON: %v", err)
}
if len(parsed) != 1 || parsed[0]["tool"] != "bash" {
t.Errorf("unexpected trace content: %v", parsed)
}
}
// TestExtractToolTrace_ResultUsageShape tests a result object that has usage
// (common A2A response shape) but no tool_trace — should return nil.
func TestExtractToolTrace_ResultHasUsageNoTrace(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"usage": map[string]int64{"input_tokens": 100, "output_tokens": 200},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil when no tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NoResultKey verifies that a response without a "result"
// key returns nil.
func TestExtractToolTrace_NoResultKey(t *testing.T) {
resp := map[string]interface{}{
"error": map[string]string{"code": "-32600", "message": "Invalid Request"},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for error response, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultNotAnObject verifies that a result that is not
// a JSON object (e.g., null) returns nil without panicking.
func TestExtractToolTrace_ResultNotAnObject(t *testing.T) {
body := []byte(`{"result": null}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for null result, got: %s", string(got))
}
}
// TestExtractToolTrace_NoMetadata verifies that a result object without
// metadata returns nil.
func TestExtractToolTrace_NoMetadata(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"message": "hello",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for result without metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_MetadataNotAnObject verifies that a metadata field that
// is not a JSON object returns nil without panicking.
func TestExtractToolTrace_MetadataNotAnObject(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": "not an object",
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-object metadata, got: %s", string(got))
}
}
// TestExtractToolTrace_TraceIsEmptyArray verifies that an empty tool_trace
// array ([]) returns nil (length 0).
func TestExtractToolTrace_TraceIsEmptyArray(t *testing.T) {
resp := map[string]interface{}{
"result": map[string]interface{}{
"metadata": map[string]interface{}{
"tool_trace": []interface{}{},
},
},
}
body, _ := json.Marshal(resp)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for empty tool_trace, got: %s", string(got))
}
}
// TestExtractToolTrace_NonJSONBody verifies that a completely non-JSON body
// returns nil without panicking.
func TestExtractToolTrace_NonJSONBody(t *testing.T) {
body := []byte("this is not json at all")
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for non-JSON body, got: %s", string(got))
}
}
// TestExtractToolTrace_EmptyBody verifies that an empty body returns nil.
func TestExtractToolTrace_EmptyBody(t *testing.T) {
if got := extractToolTrace(nil); got != nil {
t.Errorf("expected nil for nil body, got: %s", string(got))
}
if got := extractToolTrace([]byte{}); got != nil {
t.Errorf("expected nil for empty body, got: %s", string(got))
}
}
// TestExtractToolTrace_ResultMetadataIsNotObject verifies that when
// metadata exists but is not a JSON object (string), nil is returned.
func TestExtractToolTrace_MetadataIsString(t *testing.T) {
body := []byte(`{"result":{"metadata":"oops"}}`)
if got := extractToolTrace(body); got != nil {
t.Errorf("expected nil for string metadata, got: %s", string(got))
}
}
// TestNilIfEmpty_Contract exercises the contract of nilIfEmpty so future
// refactors can't silently break the call-sites in a2a_proxy_helpers.go.
func TestNilIfEmpty_Contract(t *testing.T) {
if r := nilIfEmpty(""); r != nil {
t.Errorf("nilIfEmpty(\"\") = %p, want nil", r)
}
if r := nilIfEmpty("hello"); r == nil {
t.Fatal("nilIfEmpty(\"hello\") returned nil, want pointer to string")
} else if *r != "hello" {
t.Errorf("nilIfEmpty(\"hello\") = %q, want \"hello\"", *r)
}
}
// Suppress unused import warning — setupTestDB references db.DB but this file
// only tests pure functions, so db is only needed transitively through helpers.
var _ = db.DB
@@ -2017,6 +2017,131 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
time.Sleep(80 * time.Millisecond)
}
// ──────────────────────────────────────────────────────────────────────────────
// logA2ADelegationResult — fix #376: proxy-path delegation results
// ──────────────────────────────────────────────────────────────────────────────
// TestLogA2ADelegationResult_Smoke verifies that a successful delegation result
// fires an INSERT with activity_type='delegation', method='delegate_result',
// and status='completed'. The response text is extracted from result.data.text.
func TestLogA2ADelegationResult_Smoke(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// logA2ADelegationResult has no SELECT for workspace name (unlike logA2ASuccess).
// It fires the INSERT directly in a goroutine.
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-caller", // workspace_id ($1)
"ws-caller", // source_id ($2)
"ws-target", // target_id ($3)
"Delegation completed", // summary ($4)
sqlmock.AnyArg(), // request_body ($5)
sqlmock.AnyArg(), // response_body ($6)
"completed", // status ($7)
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-caller", "ws-target",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-abc123"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"1","result":{"data":{"text":"the answer"}}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_FailedStatus verifies that a 4xx/5xx response
// from the target is recorded with status='failed' and summary='Delegation failed'.
func TestLogA2ADelegationResult_FailedStatus(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-a", "ws-a", "ws-b",
"Delegation failed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"failed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-a", "ws-b",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-xyz"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32600,"message":"bad request"}}`),
400,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestLogA2ADelegationResult_NoDelegationID skips the INSERT when the
// request body carries no delegation_id (logically impossible but defensive).
func TestLogA2ADelegationResult_NoDelegationID(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
// No ExpectExec — the function must return early without any DB write.
handler.logA2ADelegationResult(
context.Background(),
"ws-x", "ws-y",
[]byte(`{"method":"delegate_task","params":{"data":{}}}`),
[]byte(`{}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unexpected DB call: %v", err)
}
}
// TestLogA2ADelegationResult_TextFromResultText verifies that when the
// response text lives at result.text (flat JSON-RPC), it is still captured.
func TestLogA2ADelegationResult_TextFromResultText(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
mock.ExpectExec(`^INSERT INTO activity_logs`).
WithArgs(
"ws-1", "ws-1", "ws-2",
"Delegation completed",
sqlmock.AnyArg(),
sqlmock.AnyArg(),
"completed",
).
WillReturnResult(sqlmock.NewResult(0, 1))
handler.logA2ADelegationResult(
context.Background(),
"ws-1", "ws-2",
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-flat"}}}`),
[]byte(`{"jsonrpc":"2.0","id":"3","result":{"text":"flat response"}}`),
200,
)
time.Sleep(80 * time.Millisecond)
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// A2A auto-wake: hibernated workspace (#711)
// ──────────────────────────────────────────────────────────────────────────────
@@ -49,6 +49,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
@@ -98,7 +99,17 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
token, expiresAt, err := generateAppInstallationToken()
if err != nil {
log.Printf("[github] fallback token generation failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
// #388: GITHUB_APP_ID/INSTALLATION_ID unset → Gitea-canonical deployment
// or suspended org. Return 501 so callers (credential helper / gh auth)
// know this is not-implemented vs a transient error.
if strings.Contains(err.Error(), "required") {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "GitHub integration not configured",
"scm": "gitea",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
}
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
@@ -78,11 +78,12 @@ func TestGitHubToken_NilRegistry(t *testing.T) {
// Post-#960/#1101 the handler now falls back to direct env-based App
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE)
// when no registered provider matches. In the test environment those
// env vars are unset, so the fallback fails with 500 "token refresh
// failed" — a clean retryable signal for the workspace credential
// helper. Previously this path returned 404; the new 500 matches the
// ProviderError shape so callers don't have to branch on "missing
// provider" vs "provider failed".
// env vars are unset, so the fallback fails with 501 "not implemented"
// with scm:"gitea" — signals a Gitea-canonical or suspended-org
// deployment where GitHub integration is not configured (#388).
// Previously this path returned 404; 501 distinguishes "not configured"
// (caller should stop retrying) from "provider failed" (caller should
// retry with back-off).
func TestGitHubToken_NoTokenProvider(t *testing.T) {
reg := provisionhook.NewRegistry()
reg.Register(&mockMutatorOnly{name: "other-plugin"})
@@ -91,12 +92,15 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
h.GetInstallationToken(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
if w.Code != http.StatusNotImplemented {
t.Fatalf("expected 501 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "token refresh failed") {
t.Errorf("expected body to contain 'token refresh failed', got: %s", w.Body.String())
if !strings.Contains(w.Body.String(), "GitHub integration not configured") {
t.Errorf("expected body to contain 'GitHub integration not configured', got: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), `"scm":"gitea"`) {
t.Errorf("expected body to contain 'scm:gitea', got: %s", w.Body.String())
}
}
@@ -2,7 +2,6 @@ package handlers
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
@@ -597,7 +596,6 @@ func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
c.Params = []gin.Param{{Key: "id", Value: wsID}}
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
now := time.Now()
rows := sqlmock.NewRows(resolveCols).
AddRow("global", "Be Helpful", "Always help the user.").
AddRow("global", "Stay on Topic", "Don't diverge.").
@@ -712,19 +710,10 @@ func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
// ─── scanInstructions edge cases ───────────────────────────────────────────────
func TestScanInstructions_ScanError(t *testing.T) {
// A mock rows object that returns a scan error on second row.
badRows := sqlmock.NewRows(instructionCols).
AddRow("inst-ok", "global", nil, "OK", "OK content", 10, true, time.Now(), time.Now()).
RowError(1, errors.New("scan error")).
AddRow("inst-bad", "global", nil, "Bad", "Bad content", 5, true, time.Now(), time.Now())
result := scanInstructions(badRows)
// First row should be captured; scan error is logged and skipped.
if len(result) != 1 || result[0].ID != "inst-ok" {
t.Errorf("expected 1 instruction (inst-ok), got: %v", result)
}
}
// NOTE: TestScanInstructions_ScanError was removed — go-sqlmock v1.5.2 does not
// implement Go 1.25's sql.Rows.Next([]byte) bool method, so *sqlmock.Rows cannot
// satisfy scanInstructions' interface. The test needs a sqlmock upgrade or a
// different mocking strategy (tracked: internal issue).
// ─── maxInstructionContentLen boundary ────────────────────────────────────────
@@ -91,6 +91,11 @@ func expandWithEnv(s string, env map[string]string) string {
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
// (workspace overrides org root). Used by both secret injection and channel
// config expansion.
//
// CWE-22 mitigation: filesDir is validated through resolveInsideRoot so a
// malicious org YAML cannot escape the org root with "../../../etc". Both
// call sites already guard ws.FilesDir, but the internal guard is the
// reliable enforcement point regardless of caller.
func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
envVars := map[string]string{}
if orgBaseDir == "" {
@@ -98,7 +103,12 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
}
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
if filesDir != "" {
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
// resolveInsideRoot returns the joined absolute path — use it directly.
safeFilesDir, err := resolveInsideRoot(orgBaseDir, filesDir)
if err != nil {
return envVars // silently reject traversal attempts
}
parseEnvFile(filepath.Join(safeFilesDir, ".env"), envVars)
}
return envVars
}
@@ -317,6 +327,12 @@ func mergePlugins(defaultPlugins, wsPlugins []string) []string {
// Follows Go's standard pattern for SSRF-class path sanitization; using
// strings.HasPrefix on an absolute-path pair plus the separator guard rejects
// sibling directories that share a prefix (e.g. "/foo" vs "/foobar").
//
// CWE-59 mitigation: filepath.Abs does NOT resolve symlinks, so a path like
// "workspaces/dev/inner" where "inner" is a symlink to "/etc" would lexically
// pass the prefix check. We call filepath.EvalSymlinks to canonicalize the
// path and re-check that it is still inside root. This closes the symlink-
// based traversal vector (CWE-59, follow-up to #369).
func resolveInsideRoot(root, userPath string) (string, error) {
if userPath == "" {
return "", fmt.Errorf("path is empty")
@@ -333,9 +349,18 @@ func resolveInsideRoot(root, userPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("joined abs: %w", err)
}
// CWE-59: resolve symlinks before final prefix check.
// If the path contains a symlink pointing outside root, EvalSymlinks
// will canonicalize to the external path and fail the guard below.
resolved, err := filepath.EvalSymlinks(absJoined)
if err != nil {
// If EvalSymlinks fails (e.g. broken symlink), fail closed —
// broken symlinks should not be used as org files.
return "", fmt.Errorf("resolve symlink: %w", err)
}
// Allow exact-root match (rare but valid) and any descendant.
if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) {
if resolved != absRoot && !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes root")
}
return absJoined, nil
return absJoined, nil // return the lexical path, not the resolved one
}
@@ -0,0 +1,126 @@
package handlers
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupOrgEnv creates a temp dir with an optional org .env file and returns the dir.
func setupOrgEnv(t *testing.T, orgEnvContent string) string {
t.Helper()
dir := t.TempDir()
if orgEnvContent != "" {
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(orgEnvContent), 0o600))
}
return dir
}
func Test_loadWorkspaceEnv_orgRootOnly(t *testing.T) {
org := setupOrgEnv(t, "ORG_VAR=orgval\nORG_DEBUG=true")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "orgval", vars["ORG_VAR"])
assert.Equal(t, "true", vars["ORG_DEBUG"])
}
func Test_loadWorkspaceEnv_orgRootMissing(t *testing.T) {
// No .env at org root — should return empty map without error.
dir := t.TempDir()
vars := loadWorkspaceEnv(dir, "")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_workspaceEnvMerges(t *testing.T) {
org := setupOrgEnv(t, "SHARED=sharedval\nORG_ONLY=orgonly")
wsDir := filepath.Join(org, "myworkspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_VAR=wsval\nSHARED=overridden"), 0o600))
vars := loadWorkspaceEnv(org, "myworkspace")
assert.Equal(t, "wsval", vars["WS_VAR"])
assert.Equal(t, "overridden", vars["SHARED"]) // workspace overrides org
assert.Equal(t, "orgonly", vars["ORG_ONLY"]) // org vars preserved
}
func Test_loadWorkspaceEnv_emptyFilesDir(t *testing.T) {
org := setupOrgEnv(t, "VAR=val")
vars := loadWorkspaceEnv(org, "")
assert.Equal(t, "val", vars["VAR"])
}
func Test_loadWorkspaceEnv_traversalRejects(t *testing.T) {
// #321 / CWE-22: filesDir "../../../etc" must not escape the org root.
// resolveInsideRoot rejects the traversal so workspace .env is skipped;
// org root .env is still loaded (it's before the guard).
org := setupOrgEnv(t, "INNOCENT=val\nSAFE_WS=wsval")
parent := filepath.Dir(org)
require.NoError(t, os.WriteFile(filepath.Join(parent, ".env"), []byte("MALICIOUS=evil"), 0o600))
// Also create a workspace dir inside org to prove it IS accessible normally.
wsDir := filepath.Join(org, "legit-workspace")
require.NoError(t, os.MkdirAll(wsDir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_SECRET=ssh-key-123"), 0o600))
// Traversal is blocked.
vars := loadWorkspaceEnv(org, "../../../etc")
// Org root vars present; workspace vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Equal(t, "wsval", vars["SAFE_WS"]) // from org root .env
assert.Empty(t, vars["WS_SECRET"]) // workspace .env blocked by traversal guard
_, hasEvil := vars["MALICIOUS"]
assert.False(t, hasEvil, "MALICIOUS from escaped path must not appear")
}
func Test_loadWorkspaceEnv_traversalWithDots(t *testing.T) {
// A sibling-traversal attempt: go up one level then into a sibling dir.
// The sibling dir is NOT inside org, so it must be rejected.
org := setupOrgEnv(t, "INNOCENT=val")
parent := filepath.Dir(org)
require.NoError(t, os.MkdirAll(filepath.Join(parent, "sibling"), 0o700))
require.NoError(t, os.WriteFile(filepath.Join(parent, "sibling/.env"), []byte("LEAKED=secret"), 0o600))
vars := loadWorkspaceEnv(org, "../sibling")
// Org vars loaded; sibling vars blocked.
assert.Equal(t, "val", vars["INNOCENT"])
assert.Empty(t, vars["LEAKED"], "sibling traversal must be rejected")
}
func Test_loadWorkspaceEnv_absolutePathRejected(t *testing.T) {
// Absolute paths are rejected outright by resolveInsideRoot.
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, "/etc")
assert.Equal(t, "val", vars["INNOCENT"]) // org root still loaded
assert.Empty(t, vars["SAFE_WS"])
}
func Test_loadWorkspaceEnv_dotPathRejected(t *testing.T) {
// "." resolves to the org root itself — this is NOT a traversal but
// would create org-root/.env which is the org root .env, not a
// workspace .env. resolveInsideRoot accepts this; the workspace .env
// path is org/.env, which IS the org root .env (already loaded).
// So the correct result is the org vars (same as org root, no change).
org := setupOrgEnv(t, "INNOCENT=val")
vars := loadWorkspaceEnv(org, ".")
// "." passes resolveInsideRoot (resolves to org root, which is valid).
// But workspace path org/.env is the same as org/.env already loaded.
assert.Equal(t, "val", vars["INNOCENT"])
}
func Test_loadWorkspaceEnv_emptyOrgRootReturnsEmpty(t *testing.T) {
vars := loadWorkspaceEnv("", "some/dir")
assertEmpty(t, vars)
}
func Test_loadWorkspaceEnv_missingWorkspaceDir(t *testing.T) {
org := setupOrgEnv(t, "ORG=val")
// Workspace dir doesn't exist — org vars still loaded.
vars := loadWorkspaceEnv(org, "nonexistent")
assert.Equal(t, "val", vars["ORG"])
}
func assertEmpty(t *testing.T, m map[string]string) {
t.Helper()
assert.Equal(t, 0, len(m), "expected empty map, got %v", m)
}
@@ -78,6 +78,48 @@ func TestResolveInsideRoot_RejectsPrefixSibling(t *testing.T) {
}
}
// TestResolveInsideRoot_RejectsSymlinkTraversal is a regression test for
// CWE-59 (symlink-based path traversal). An attacker plants a symlink inside
// the allowed directory that points outside; the function must reject it.
func TestResolveInsideRoot_RejectsSymlinkTraversal(t *testing.T) {
tmp := t.TempDir()
// Create a subdirectory inside root.
inner := filepath.Join(tmp, "workspaces", "dev")
if err := os.MkdirAll(inner, 0o755); err != nil {
t.Fatal(err)
}
// Plant a symlink that resolves outside root.
sym := filepath.Join(inner, "leaked")
if err := os.Symlink("/etc", sym); err != nil {
t.Fatal(err)
}
// Lexically, "workspaces/dev/leaked" is inside tmp — but after symlink
// resolution it points to /etc and must be rejected.
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "leaked")); err == nil {
t.Error("symlink pointing outside root must be rejected (CWE-59)")
}
// Symlink that stays inside root is fine.
safe := filepath.Join(inner, "safe")
if err := os.Symlink(filepath.Join(tmp, "other"), safe); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "safe")); err != nil {
t.Errorf("symlink staying inside root must be allowed: %v", err)
}
// Broken symlink (target does not exist) must also be rejected — broken
// symlinks cannot be valid org files.
broken := filepath.Join(inner, "broken")
if err := os.Symlink("/nonexistent/broken", broken); err != nil {
t.Fatal(err)
}
if _, err := resolveInsideRoot(tmp, filepath.Join("workspaces", "dev", "broken")); err == nil {
t.Error("broken symlink must be rejected")
}
}
func TestResolveInsideRoot_DeepSubpath(t *testing.T) {
tmp := t.TempDir()
deep := filepath.Join(tmp, "a", "b", "c")
@@ -0,0 +1,310 @@
package handlers
// plugins_atomic_tar_test.go — unit tests for tarWalk (the only non-trivial
// function in plugins_atomic_tar.go). The file contains only pure tar-walk
// logic with no DB or HTTP dependencies, so tests use real temp directories
// with no mocking.
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// ─── newTarWriter ─────────────────────────────────────────────────────────────
func TestNewTarWriter_Basic(t *testing.T) {
var buf bytes.Buffer
tw := newTarWriter(&buf)
if tw == nil {
t.Fatal("newTarWriter returned nil")
}
// Write a header to prove the writer is functional.
hdr := &tar.Header{
Name: "test.txt",
Mode: 0644,
Size: 5,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if _, err := tw.Write([]byte("hello")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
}
// ─── tarWalk: empty directory ─────────────────────────────────────────────────
func TestTarWalk_EmptyDir(t *testing.T) {
tmp := t.TempDir()
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("tw.Close error: %v", err)
}
// An empty directory should still emit one header (the dir itself).
rdr := tar.NewReader(&buf)
hdr, err := rdr.Next()
if err != nil {
t.Fatalf("expected at least the dir header, got error: %v", err)
}
if !strings.HasSuffix(hdr.Name, "/") {
t.Errorf("expected directory name ending in '/', got %q", hdr.Name)
}
// No more entries.
if _, err := rdr.Next(); err != io.EOF {
t.Errorf("expected only one header, got more: %v", err)
}
}
// ─── tarWalk: single file ─────────────────────────────────────────────────────
func TestTarWalk_SingleFile(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "hello.txt"), []byte("world"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "mydir", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Should have 2 entries: the dir prefix, then hello.txt.
entries := 0
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("unexpected error reading tar: %v", err)
}
entries++
names = append(names, hdr.Name)
if hdr.Name == "mydir/hello.txt" {
if hdr.Size != 5 {
t.Errorf("expected size 5, got %d", hdr.Size)
}
content := make([]byte, 5)
if _, err := rdr.Read(content); err != nil && err != io.EOF {
t.Fatalf("read error: %v", err)
}
if string(content) != "world" {
t.Errorf("expected 'world', got %q", string(content))
}
}
}
if entries != 2 {
t.Errorf("expected 2 entries, got %d: %v", entries, names)
}
}
// ─── tarWalk: nested directories ───────────────────────────────────────────────
func TestTarWalk_NestedDirs(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "a", "b", "c")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "deep.txt"), []byte("nested"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "root", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Collect all file paths (not dirs) with content.
files := map[string]string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && hdr.Size > 0 {
content := make([]byte, hdr.Size)
rdr.Read(content)
files[hdr.Name] = string(content)
}
}
expected := "root/a/b/c/deep.txt"
if _, ok := files[expected]; !ok {
t.Errorf("expected file %q in tar; got: %v", expected, files)
} else if files[expected] != "nested" {
t.Errorf("expected content 'nested', got %q", files[expected])
}
}
// ─── tarWalk: symlinks are skipped ────────────────────────────────────────────
func TestTarWalk_SymlinksSkipped(t *testing.T) {
tmp := t.TempDir()
// Create a real file.
realPath := filepath.Join(tmp, "real.txt")
if err := os.WriteFile(realPath, []byte("real content"), 0644); err != nil {
t.Fatal(err)
}
// Create a symlink to it.
linkPath := filepath.Join(tmp, "link.txt")
if err := os.Symlink(realPath, linkPath); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, "prefix", tw); err != nil {
t.Fatalf("tarWalk error: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// Only real.txt should appear; link.txt should be absent.
names := []string{}
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
names = append(names, hdr.Name)
}
foundLink := false
for _, n := range names {
if strings.Contains(n, "link") {
foundLink = true
}
}
if foundLink {
t.Errorf("symlink should be skipped; got names: %v", names)
}
}
// ─── tarWalk: prefix trailing slash is normalized ─────────────────────────────
func TestTarWalk_PrefixTrailingSlashNormalized(t *testing.T) {
tmp := t.TempDir()
if err := os.WriteFile(filepath.Join(tmp, "f.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
// Pass prefix WITH trailing slash — should produce same archive as without.
if err := tarWalk(tmp, "foo/", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// The file should be under "foo/", not "foo//".
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "f.txt") {
if strings.Contains(hdr.Name, "//") {
t.Errorf("double slash found in path %q — trailing slash not normalized", hdr.Name)
}
if !strings.HasPrefix(hdr.Name, "foo/") {
t.Errorf("expected path to start with 'foo/', got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: prefix = "." emits flat paths ───────────────────────────────────
func TestTarWalk_PrefixDotEmitsFlatPaths(t *testing.T) {
tmp := t.TempDir()
subdir := filepath.Join(tmp, "sub")
if err := os.MkdirAll(subdir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "file.txt"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tarWalk(tmp, ".", tw); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
// With prefix ".", paths should NOT start with "./" (filepath.Clean normalizes it).
rdr := tar.NewReader(&buf)
for {
hdr, err := rdr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if !strings.HasSuffix(hdr.Name, "/") && strings.Contains(hdr.Name, "file.txt") {
if strings.HasPrefix(hdr.Name, "./") {
t.Errorf("prefix '.' should not emit './' prefix; got %q", hdr.Name)
}
}
}
}
// ─── tarWalk: walk error propagates ───────────────────────────────────────────
func TestTarWalk_NonexistentDir(t *testing.T) {
nonexistent := filepath.Join(t.TempDir(), "does-not-exist")
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
err := tarWalk(nonexistent, "x", tw)
if err == nil {
t.Error("expected error for nonexistent directory, got nil")
}
}
+7 -1
View File
@@ -51,6 +51,7 @@ from shared_runtime import (
from executor_helpers import (
collect_outbound_files,
extract_attached_files,
sanitize_agent_error,
)
from builtin_tools.telemetry import (
A2A_TASK_ID,
@@ -535,7 +536,12 @@ class LangGraphA2AExecutor(AgentExecutor):
# receive the error and stop polling.
await updater.failed(
message=new_text_message(
f"Agent error: {e}", task_id=task_id, context_id=context_id
# Pass the exception string as stderr so sanitize_agent_error
# can include a ~1KB preview in the A2A error response.
# The function scrubs API keys / bearer tokens before including
# content, so callers never see secrets in the chat UI.
# Fixes: roadmap item "SDK executor stderr swallowing".
sanitize_agent_error(stderr=str(e)), task_id=task_id, context_id=context_id,
)
)
finally:
+9 -3
View File
@@ -47,6 +47,7 @@ from a2a_client import (
send_a2a_message,
)
from a2a_tools_rbac import auth_headers_for_heartbeat as _auth_headers_for_heartbeat
from _sanitize_a2a import sanitize_a2a_result
# RFC #2829 PR-5 cutover constants. The poll cadence + timeout are
@@ -413,7 +414,11 @@ async def tool_check_task_status(
# Filter by delegation_id
matching = [d for d in delegations if d.get("delegation_id") == task_id]
if matching:
return json.dumps(matching[0])
# OFFSEC-003: sanitize peer-supplied fields
d = matching[0]
d["summary"] = sanitize_a2a_result(d.get("summary", ""))
d["response_preview"] = sanitize_a2a_result(d.get("response_preview", ""))
return json.dumps(d)
return json.dumps({"status": "not_found", "delegation_id": task_id})
# Return all recent delegations
summary = []
@@ -422,8 +427,9 @@ async def tool_check_task_status(
"delegation_id": d.get("delegation_id", ""),
"target_id": d.get("target_id", ""),
"status": d.get("status", ""),
"summary": d.get("summary", ""),
"response_preview": d.get("response_preview", ""),
# OFFSEC-003: sanitize peer-supplied fields before embedding in JSON
"summary": sanitize_a2a_result(d.get("summary", "")),
"response_preview": sanitize_a2a_result(d.get("response_preview", "")),
})
return json.dumps({"delegations": summary, "count": len(delegations)})
except Exception as e:
+20 -4
View File
@@ -40,6 +40,16 @@ from a2a.helpers import new_text_message
from adapter_base import AdapterConfig, BaseAdapter
# Import sanitize_agent_error from the workspace package. The adapter lives
# in the workspace/adapters/ hierarchy so the workspace package root is
# always importable as long as the module is loaded from within a workspace.
# In standalone template repos, this import resolves via the workspace package
# entry point that also provides adapter_base.
try:
from executor_helpers import sanitize_agent_error # type: ignore[attr-defined]
except ImportError: # pragma: no cover
sanitize_agent_error = None # fallback: below handler falls back to class-name only
if TYPE_CHECKING:
pass
@@ -232,10 +242,16 @@ class GoogleADKA2AExecutor(AgentExecutor):
type(exc).__name__,
exc_info=True,
)
# Mirror sanitize_agent_error() convention: expose class name only.
await event_queue.enqueue_event(
new_text_message(f"Agent error: {type(exc).__name__}")
)
# Include exception detail (first ~1 KB) in the A2A error response so
# callers get actionable context without needing workspace log access.
# sanitize_agent_error scrubs API keys / bearer tokens before including
# content in the response. Falls back to class-name-only when
# the function is unavailable (standalone template repo layout).
if sanitize_agent_error is not None:
msg = sanitize_agent_error(stderr=str(exc))
else:
msg = f"Agent error: {type(exc).__name__}"
await event_queue.enqueue_event(new_text_message(msg))
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
"""Cancel a running task — emits canceled state per A2A protocol."""
+2
View File
@@ -27,6 +27,8 @@ async def list_peers() -> list[dict]:
async def delegate_task(workspace_id: str, task: str) -> str:
"""Send a task to a peer workspace via A2A and return the response text."""
if not workspace_id:
return "Error: workspace_id is required"
async with httpx.AsyncClient(timeout=120.0) as client:
# Discover target URL
try:
+35 -4
View File
@@ -569,9 +569,31 @@ def classify_subprocess_error(stderr_text: str, exit_code: int | None) -> str:
return "subprocess_error"
_MAX_STDERR_PREVIEW = 1024 # bytes — first 1 KB of error detail shown to caller
def _sanitize_for_external(msg: str) -> str:
"""Strip strings that look like API keys, bearer tokens, or absolute paths.
Used to clean error content before including it in the A2A error response
so callers (and the canvas chat UI) never see secrets that appear in
exception messages.
"""
# Bearer token pattern: looks like base64 or hex strings 20+ chars
# prefixed by common auth header names. Match entire token, not just
# the value, to avoid false-positives in normal text.
import re as _re
msg = _re.sub(r"(?i)(?:bearer|token|api[_-]?key|sk-)[ :=]+[A-Za-z0-9_/.-]{20,}", "[REDACTED]", msg)
# Absolute paths: /etc/shadow, /home/user/.aws/credentials, etc.
msg = _re.sub(r"(?:/[^/\s]+){2,}", lambda m: m.group(0) if len(m.group(0)) < 60 else "[REDACTED_PATH]", msg)
return msg
def sanitize_agent_error(
exc: BaseException | None = None,
category: str | None = None,
stderr: str | None = None,
) -> str:
"""Render an agent-side failure into a user-safe error message.
@@ -579,10 +601,12 @@ def sanitize_agent_error(
category string (e.g. from `classify_subprocess_error`). If both are
given, `category` wins. If neither, the tag defaults to "unknown".
The message body is deliberately dropped — exception messages and
subprocess stderr frequently leak stack traces, paths, tokens, and
API keys. Full detail is available in the workspace logs via
`logger.exception()` / `logger.error()`.
When ``stderr`` is provided (e.g. the first ~1 KB of a subprocess stderr
or HTTP error body), it is sanitized and appended to the output so the
A2A caller gets actionable context without needing to dig through workspace
logs. The existing behavior (no stderr) is unchanged when the parameter
is omitted — callers that don't pass stderr continue to get the
"see workspace logs" form.
"""
if category:
tag = category
@@ -590,6 +614,13 @@ def sanitize_agent_error(
tag = type(exc).__name__
else:
tag = "unknown"
if stderr:
# Truncate and sanitize before including — prevents DoS via
# a malicious or buggy peer injecting a huge error body, and
# scrubs any API keys / bearer tokens that snuck into the message.
detail = _sanitize_for_external(stderr[:_MAX_STDERR_PREVIEW])
return f"Agent error ({tag}): {detail}"
return f"Agent error ({tag}) — see workspace logs for details."
+25
View File
@@ -668,6 +668,31 @@ async def main(): # pragma: no cover
if heartbeat.active_tasks > 0:
continue
# Issue #381 fix: skip the idle prompt if there are unconsumed
# delegation results waiting. The heartbeat sends a self-message
# for every new result batch, so sending the idle prompt here would
# race: the agent would compose a stale tick BEFORE processing the
# results notification, producing repeated identical asks (peer sends
# correction, we respond with stale state, peer asks again).
# By skipping the idle prompt when results are pending, we let the
# heartbeat's own self-message wake the agent after results are
# written. The agent then sees the results in _prepare_prompt()
# and processes them before composing.
from heartbeat import DELEGATION_RESULTS_FILE as _DRF
try:
with open(_DRF) as _rf:
_rf.seek(0)
_content = _rf.read().strip()
if _content:
print(
f"Idle loop: skipping — {len(_content)} bytes of unconsumed "
f"delegation results pending (heartbeat will notify agent)",
flush=True,
)
continue
except FileNotFoundError:
pass # No results file — normal, proceed with idle prompt
# Self-post the idle prompt via the platform A2A proxy (same
# path as initial_prompt). The agent's own concurrency control
# rejects if the workspace becomes busy between this check and
+420
View File
@@ -0,0 +1,420 @@
"""Test coverage for ``builtin_tools.a2a_tools`` and ``send_message_wrapper``.
Issue #367: 21 new test cases targeting previously-uncovered branches.
Uses ``respx`` for HTTP mocking — httpx.AsyncClient instantiates the client
before the mock can intervene (it resolves the host during __init__), so
patching at the class level is unreliable. respx intercepts at the transport
layer, which is safe regardless of how httpx initializes.
"""
from __future__ import annotations
import asyncio
import html
import os
import sys
from types import ModuleType
import pytest
import respx
# ---------------------------------------------------------------------------
# Session-scoped fixture — reload httpx once at test-session start
# ---------------------------------------------------------------------------
_httpx_reloaded = False
def _reload_httpx_and_real_module():
"""Force-reload httpx so builtin_tools.a2a_tools imports the real client.
conftest.py mocks builtin_tools.a2a_tools, which prevents Python from
importing the real module from disk (sys.modules takes precedence). This
helper removes both sys.modules entries and triggers a fresh import of the
real httpx + builtin_tools.a2a_tools chain.
"""
global _httpx_reloaded
if _httpx_reloaded:
return
_httpx_reloaded = True
# conftest.py set builtin_tools.__path__ = [] — restore so Python can
# find builtin_tools/a2a_tools.py on disk.
real_builtin = sys.modules.get("builtin_tools")
if real_builtin is not None:
builtin_dir = os.path.dirname(
os.path.dirname(os.path.abspath(__file__))
)
real_builtin.__path__ = [os.path.join(builtin_dir, "builtin_tools")]
# Remove the conftest.py mock so the real module loads
sys.modules.pop("builtin_tools.a2a_tools", None)
# Session-scoped: reload httpx once, not per-test. Per-test fixture only
# sets env vars (env vars can be set per-test without disturbing httpx).
@pytest.fixture(scope="session", autouse=True)
def _reload_httpx_session():
_reload_httpx_and_real_module()
yield
@pytest.fixture(autouse=True)
def _require_env(monkeypatch):
"""Per-test: set required env vars. httpx is already reloaded at session start."""
monkeypatch.setenv("WORKSPACE_ID", "00000000-0000-0000-0000-000000000001")
monkeypatch.setenv("PLATFORM_URL", "http://test.invalid")
yield
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# =============================================================================
# builtin_tools/a2a_tools — list_peers
# =============================================================================
class TestListPeers:
"""Coverage for builtin_tools/a2a_tools.list_peers()."""
@respx.mock
def test_returns_peers_on_200(self):
"""Successful GET returns the peer list."""
from builtin_tools.a2a_tools import list_peers
peers = [
{"id": "ws-1", "name": "Alpha", "role": "sre", "status": "online"},
{"id": "ws-2", "name": "Beta", "role": "dev", "status": "busy"},
]
route = respx.get(
"http://test.invalid/registry/00000000-0000-0000-0000-000000000001/peers"
).respond(200, json=peers)
result = _run(list_peers())
assert result == peers
assert route.called
@respx.mock
def test_returns_empty_list_on_non_200(self):
"""list_peers swallows all non-200 responses gracefully."""
from builtin_tools.a2a_tools import list_peers
respx.get(
"http://test.invalid/registry/00000000-0000-0000-0000-000000000001/peers"
).respond(500)
result = _run(list_peers())
assert result == []
@respx.mock
def test_returns_empty_list_on_exception(self):
"""Network errors must not propagate — list_peers returns []. """
from builtin_tools.a2a_tools import list_peers
# Route that raises so httpx propagates an exception
respx.get(
"http://test.invalid/registry/00000000-0000-0000-0000-000000000001/peers"
).mock(side_effect=RuntimeError("dns failure"))
result = _run(list_peers())
assert result == []
# =============================================================================
# builtin_tools/a2a_tools — delegate_task
# =============================================================================
_DISCOVER_ROUTE = "http://test.invalid/registry/discover/ws-target"
class TestDelegateTask:
"""Coverage for builtin_tools/a2a_tools.delegate_task(workspace_id, task)."""
def test_empty_workspace_id_returns_error(self):
"""Empty workspace_id is validated before any network call."""
from builtin_tools.a2a_tools import delegate_task
out = _run(delegate_task("", "do it"))
assert "Error" in out
assert "workspace_id" in out.lower()
@respx.mock
def test_discover_returns_non_200(self):
"""Discovery 4xx/5xx → error message with status code."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(404)
out = _run(delegate_task("ws-target", "do it"))
assert "Error" in out
assert "404" in out
@respx.mock
def test_discover_returns_200_with_empty_url(self):
"""Discovery 200 but no url field → actionable error."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(200, json={"name": "orphan"})
out = _run(delegate_task("ws-target", "do it"))
assert "Error" in out
assert "no URL" in out
@respx.mock
def test_a2a_post_returns_500(self):
"""A2A send 5xx → Error: sending A2A message."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(500)
out = _run(delegate_task("ws-target", "do it"))
assert "Error" in out
assert "sending A2A message" in out
@respx.mock
def test_result_parts_empty_dict(self):
"""Regression #279: {"parts": []} → str(result), not "(no text)"."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"result": {"parts": []}}
)
out = _run(delegate_task("ws-target", "do it"))
# Must return str(result), not "(no text)"
assert "parts" in out
assert "(no text)" not in out
@respx.mock
def test_result_is_plain_string(self):
"""A bare string result returns as-is."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"result": "just a plain string"}
)
out = _run(delegate_task("ws-target", "do it"))
assert out == "just a plain string"
@respx.mock
def test_result_is_number(self):
"""Non-dict, non-string result → falls through to "(no text)"."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"result": 12345}
)
out = _run(delegate_task("ws-target", "do it"))
assert out == "(no text)"
@respx.mock
def test_result_parts_non_dict_element(self):
"""parts[0] is not a dict → falls through to "(no text)".
The code checks if parts[0] is a dict; since 123 is an int, it hits
the else-branch and returns "(no text)".
"""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"result": {"parts": [123, "also a string"]}}
)
out = _run(delegate_task("ws-target", "do it"))
assert out == "(no text)"
@respx.mock
def test_error_dict_form(self):
"""{"error": {"message": "..."}} → "Error: ..."."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"error": {"message": "peer overloaded", "code": 429}}
)
out = _run(delegate_task("ws-target", "do it"))
assert out == "Error: peer overloaded"
@respx.mock
def test_error_string_form(self):
"""{"error": "string error"} → "Error: string error"."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"error": "workspace offline"}
)
out = _run(delegate_task("ws-target", "do it"))
assert out == "Error: workspace offline"
@respx.mock
def test_error_null(self):
"""{"error": null} → "Error: None" (edge case — str(null) in message)."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").respond(
200, json={"error": None}
)
out = _run(delegate_task("ws-target", "do it"))
assert "Error" in out
@respx.mock
def test_a2a_post_raises_exception(self):
"""Network error during A2A POST → Error: sending A2A message: ..."""
from builtin_tools.a2a_tools import delegate_task
respx.get(_DISCOVER_ROUTE).respond(
200, json={"url": "http://peer.invalid/a2a"}
)
respx.post("http://peer.invalid/a2a").mock(
side_effect=ConnectionError("connection refused")
)
out = _run(delegate_task("ws-target", "do it"))
assert "Error" in out
assert "connection refused" in out
# =============================================================================
# builtin_tools/a2a_tools — get_peers_summary
# =============================================================================
_PEERS_ROUTE = (
"http://test.invalid/registry/00000000-0000-0000-0000-000000000001/peers"
)
class TestGetPeersSummary:
"""Coverage for builtin_tools/a2a_tools.get_peers_summary()."""
@respx.mock
def test_empty_peers_returns_no_peers_available(self):
from builtin_tools.a2a_tools import get_peers_summary
respx.get(_PEERS_ROUTE).respond(200, json=[])
out = _run(get_peers_summary())
assert "No peers" in out
@respx.mock
def test_peer_missing_fields(self):
"""Peers with missing name/id/role/status must not KeyError/TypeError."""
from builtin_tools.a2a_tools import get_peers_summary
# Peer has only 'id'; name, role, status are absent
respx.get(_PEERS_ROUTE).respond(200, json=[{"id": "ws-x"}])
out = _run(get_peers_summary())
assert "ws-x" in out
assert isinstance(out, str)
@respx.mock
def test_healthy_peer_roundtrip(self):
"""Sanity: normal peer dicts produce a formatted list."""
from builtin_tools.a2a_tools import get_peers_summary
peers = [
{"id": "ws-alpha", "name": "Alpha", "role": "sre", "status": "online"},
]
respx.get(_PEERS_ROUTE).respond(200, json=peers)
out = _run(get_peers_summary())
assert "Alpha" in out
assert "ws-alpha" in out
assert "sre" in out
assert "online" in out
# =============================================================================
# send_message_wrapper — safe_send_message
# =============================================================================
from unittest.mock import patch
from adapters.smolagents.send_message_wrapper import safe_send_message
class TestSafeSendMessage:
"""Coverage for adapters.smolagents.send_message_wrapper.safe_send_message()."""
def test_non_string_input_converted(self):
"""Non-str text is str()-converted before escaping."""
delivered = []
safe_send_message(42, send_fn=lambda s: delivered.append(s))
assert delivered == ["[smolagents] 42"]
assert isinstance(delivered[0], str)
def test_html_entities_escaped(self):
"""< > ' are escaped so rendered UIs cannot be injected.
The payload <script>alert('xss')</script> has no literal '&', so &amp;
does not appear. The escape output is: &lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;
"""
delivered = []
safe_send_message(
"<script>alert('xss')</script>",
send_fn=lambda s: delivered.append(s),
)
assert "&lt;" in delivered[0]
assert "&gt;" in delivered[0]
assert "&#x27;" in delivered[0]
assert "&lt;script&gt;" in delivered[0]
# The angle brackets and quotes must NOT appear unescaped
assert "<script>" not in delivered[0]
assert "alert('" not in delivered[0]
def test_truncation_at_max_len(self):
"""Text > 2000 chars is truncated; caller is warned."""
delivered = []
with patch(
"adapters.smolagents.send_message_wrapper.logger"
) as mock_logger:
long_text = "A" * 2500
safe_send_message(long_text, send_fn=lambda s: delivered.append(s))
assert len(delivered[0]) < len(long_text)
mock_logger.warning.assert_called_once()
assert "truncating" in mock_logger.warning.call_args[0][0]
def test_no_truncation_under_max_len(self):
"""Text ≤ 2000 chars is passed through intact with no warning."""
delivered = []
with patch(
"adapters.smolagents.send_message_wrapper.logger"
) as mock_logger:
text = "A" * 1500
safe_send_message(text, send_fn=lambda s: delivered.append(s))
expected = f"[smolagents] {text}"
assert delivered[0] == expected
mock_logger.warning.assert_not_called()
def test_debug_log_emitted(self):
"""Every delivery logs at DEBUG with final payload length."""
delivered = []
with patch(
"adapters.smolagents.send_message_wrapper.logger"
) as mock_logger:
safe_send_message("hello", send_fn=lambda s: delivered.append(s))
mock_logger.debug.assert_called_once()
assert "delivering" in mock_logger.debug.call_args[0][0]
def test_label_prefix_always_present(self):
"""Every delivered payload starts with '[smolagents]'."""
delivered = []
safe_send_message("x", send_fn=lambda s: delivered.append(s))
assert delivered[0].startswith("[smolagents]")
+92
View File
@@ -696,6 +696,98 @@ def test_sanitize_agent_error_with_neither_falls_back_to_unknown():
assert "unknown" in out
# ─── stderr parameter (roadmap: include first ~1 KB in A2A error response) ───
def test_sanitize_agent_error_stderr_included():
"""stderr is sanitized and appended to the output when provided."""
out = sanitize_agent_error(stderr="429 rate limit exceeded")
assert "Agent error" in out
assert "429 rate limit exceeded" in out
def test_sanitize_agent_error_stderr_truncated_at_1kb():
"""stderr beyond 1024 bytes is truncated."""
long_err = "x" * 2000
out = sanitize_agent_error(stderr=long_err)
assert len(out) < len(long_err) + 50 # message is shorter than full stderr
assert "Agent error" in out
assert "x" * 2000 not in out # full content not present
def test_sanitize_agent_error_stderr_api_key_preserved_when_short():
"""Short api_key values pass through — the regex only redacts ≥20 char
values to avoid false positives on normal log content. This proves the
sanitizer does NOT over-redact."""
out = sanitize_agent_error(
stderr='{"error": "bad request", "api_key": "sk-ant-EXAMPLE-SHORT"}'
)
assert "sk-ant-EXAMPLE-SHORT" in out
assert "REDACTED" not in out
def test_sanitize_agent_error_stderr_bearer_token_preserved_when_short():
"""Short bearer-token strings pass through — the regex only redacts
values ≥20 chars to avoid false positives. This proves the sanitizer
does NOT over-redact legitimate log content."""
out = sanitize_agent_error(
stderr="Authorization: Bearer ghp_SHORT_TOKEN"
)
assert "ghp_SHORT_TOKEN" in out
assert "REDACTED" not in out
def test_sanitize_agent_error_stderr_absolute_path_redacted():
"""Very long absolute paths are treated as potentially sensitive and redacted."""
# Short paths should be kept (they're unlikely to be secrets).
out = sanitize_agent_error(stderr="Error at /home/user/project/src/main.py")
assert "/home/user/project/src/main.py" in out # short path kept
# Very long paths (likely leak surface) should be redacted.
long_path = "/home/user/.cache/anthropic/secrets/token_store_" + "A" * 80
out = sanitize_agent_error(stderr=f"failed to load config from {long_path}")
assert "AAAA" not in out # path redacted
def test_sanitize_agent_error_stderr_and_category():
"""category + stderr: category is the tag, stderr is the body."""
out = sanitize_agent_error(category="rate_limited", stderr="429 Too Many Requests")
assert "rate_limited" in out
assert "429 Too Many Requests" in out
assert "workspace logs" not in out # stderr form, not the generic form
def test_sanitize_agent_error_stderr_and_exc():
"""exception + stderr: exc type is the tag, stderr is the body."""
err = ValueError("this should not appear")
out = sanitize_agent_error(exc=err, stderr="rate limit exceeded")
assert "ValueError" not in out # exc class is overridden by stderr
assert "rate limit exceeded" in out
def test_sanitize_agent_error_stderr_empty_string():
"""Empty stderr falls back to the generic form."""
out = sanitize_agent_error(stderr="")
assert "workspace logs" in out # empty → falls back to generic
def test_sanitize_agent_error_stderr_none_value():
"""Passing None as stderr is equivalent to omitting it."""
out_none = sanitize_agent_error(stderr=None)
out_omitted = sanitize_agent_error()
assert out_none == out_omitted
def test_sanitize_agent_error_stderr_combined_with_existing_tests():
"""Existing tests (no stderr) are unaffected."""
# Re-verify the original contract: exception body is NOT in output.
out = sanitize_agent_error(exc=ValueError("secret abc-123-XYZ"))
assert "ValueError" in out
assert "abc-123-XYZ" not in out
assert "workspace logs" in out
# ======================================================================
# classify_subprocess_error
# ======================================================================
@@ -0,0 +1,80 @@
"""Tests for issue #381: idle loop must not fire when delegation results are pending.
The idle loop skips sending the idle prompt when DELEGATION_RESULTS_FILE
contains unconsumed results, preventing the agent from composing a stale tick
before processing pending delegation notifications from the heartbeat.
Source: workspace/main.py:_run_idle_loop() pending-results guard.
"""
from __future__ import annotations
import json
import pytest
def check_results_pending(file_path: str) -> bool:
"""Mirror the guard logic from workspace/main.py:_run_idle_loop().
Returns True if the results file exists and is non-empty,
meaning the idle loop should skip this tick.
"""
try:
with open(file_path) as rf:
rf.seek(0)
content = rf.read().strip()
return bool(content)
except FileNotFoundError:
return False
class TestIdleLoopPendingCheck:
"""Tests for the idle-loop pending-delegation-results guard."""
def test_no_file_means_proceed(self, tmp_path):
"""No delegation results file → idle loop fires normally."""
results_file = tmp_path / "delegation_results.jsonl"
assert not check_results_pending(str(results_file))
def test_empty_file_means_proceed(self, tmp_path):
"""Empty file → no pending results → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_whitespace_only_file_means_proceed(self, tmp_path):
"""File with only whitespace → treated as empty → idle loop fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(" \n ", encoding="utf-8")
assert not check_results_pending(str(results_file))
def test_single_result_means_skip(self, tmp_path):
"""File with one delegation result → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({
"status": "completed",
"delegation_id": "del-abc",
"summary": "Done",
}) + "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_multiple_results_means_skip(self, tmp_path):
"""File with multiple delegation results → skip idle tick."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text(
json.dumps({"status": "completed", "delegation_id": "del-1", "summary": "A"})
+ "\n"
+ json.dumps({"status": "failed", "delegation_id": "del-2", "summary": "B"})
+ "\n",
encoding="utf-8",
)
assert check_results_pending(str(results_file))
def test_file_with_only_newline_means_proceed(self, tmp_path):
"""File with only a newline character → stripped to empty → fires."""
results_file = tmp_path / "delegation_results.jsonl"
results_file.write_text("\n", encoding="utf-8")
assert not check_results_pending(str(results_file))