Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01c6977a5c |
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
<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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-ink-mid hover:text-ink-mid text-xs"
|
||||
>
|
||||
<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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-ink-mid hover:text-ink-mid text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
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"
|
||||
|
||||
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
|
||||
>
|
||||
I'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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
|
||||
@@ -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-label="Dismiss modal"
|
||||
aria-hidden="true"
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[9px] text-accent hover:text-accent mt-0.5"
|
||||
>
|
||||
← 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
{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 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"
|
||||
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Remove Workspace
|
||||
</button>
|
||||
|
||||
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
|
||||
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors"
|
||||
>
|
||||
<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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
||||
>
|
||||
↻
|
||||
</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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
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"
|
||||
>
|
||||
{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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface ${
|
||||
className={`fixed top-4 left-4 z-40 w-9 h-9 flex items-center justify-center rounded-lg transition-colors ${
|
||||
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface rounded"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block"
|
||||
>
|
||||
Refresh templates
|
||||
</button>
|
||||
|
||||
@@ -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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface " +
|
||||
"flex h-6 w-6 items-center justify-center rounded transition-colors " +
|
||||
(active
|
||||
? "bg-surface-elevated text-ink shadow-sm"
|
||||
: "text-ink-mid hover:text-ink-mid")
|
||||
|
||||
@@ -23,11 +23,6 @@ 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
|
||||
@@ -65,7 +60,6 @@ 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,13 +512,6 @@ 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,93 +336,6 @@ 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
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
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,131 +2017,6 @@ 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,7 +49,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
@@ -99,17 +98,7 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
|
||||
token, expiresAt, err := generateAppInstallationToken()
|
||||
if err != nil {
|
||||
log.Printf("[github] fallback token generation failed: %v", err)
|
||||
// #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"})
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
|
||||
|
||||
@@ -78,12 +78,11 @@ 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 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).
|
||||
// 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".
|
||||
func TestGitHubToken_NoTokenProvider(t *testing.T) {
|
||||
reg := provisionhook.NewRegistry()
|
||||
reg.Register(&mockMutatorOnly{name: "other-plugin"})
|
||||
@@ -92,15 +91,12 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
|
||||
|
||||
h.GetInstallationToken(c)
|
||||
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
|
||||
w.Code, 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())
|
||||
if !strings.Contains(w.Body.String(), "token refresh failed") {
|
||||
t.Errorf("expected body to contain 'token refresh failed', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,882 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─── request helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func newPostRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
raw, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newPutRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
raw, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newDeleteRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newGetRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
return w, c
|
||||
}
|
||||
|
||||
// ─── mock row helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// instructionCols matches the SELECT in List/Resolve.
|
||||
var instructionCols = []string{
|
||||
"id", "scope", "scope_target", "title", "content",
|
||||
"priority", "enabled", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// resolveCols matches the SELECT in Resolve (scope, title, content).
|
||||
var resolveCols = []string{"scope", "title", "content"}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsList_ByWorkspaceID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-123-abc"
|
||||
w, c := newGetRequest("/instructions?workspace_id=" + wsID)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?workspace_id="+wsID, nil)
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols).
|
||||
AddRow("inst-1", "global", nil, "Be helpful", "Always be helpful.", 10, true, time.Now(), time.Now()).
|
||||
AddRow("inst-2", "workspace", &wsID, "Use Claude", "Use Claude Code.", 5, true, time.Now(), time.Now())
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Errorf("expected 2 instructions, got %d", len(out))
|
||||
}
|
||||
if out[0].Scope != "global" {
|
||||
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_ByScope(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions?scope=global")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols).
|
||||
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WithArgs("global").
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Scope != "global" {
|
||||
t.Errorf("unexpected response: %v", out)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_AllNoParams(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols)
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// Empty slice, not nil
|
||||
if out == nil {
|
||||
t.Error("expected empty slice, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Be Helpful",
|
||||
"content": "Always be helpful to the user.",
|
||||
"priority": 10,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out["id"] != "new-inst-1" {
|
||||
t.Errorf("expected id new-inst-1, got %s", out["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ValidWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
wsTarget := "ws-xyz-789"
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": wsTarget,
|
||||
"title": "Use Claude Code",
|
||||
"content": "Prefer Claude Code for all tasks.",
|
||||
"priority": 5,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("workspace", &wsTarget, "Use Claude Code", "Prefer Claude Code for all tasks.", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-inst-2"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"title": "Missing Scope",
|
||||
"content": "This has no scope.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingTitle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"content": "Has no title.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingContent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Has no content",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InvalidScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "team",
|
||||
"title": "Bad Scope",
|
||||
"content": "Team scope is not supported yet.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Missing Target",
|
||||
"content": "Workspace scope without scope_target.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
// Build a string longer than maxInstructionContentLen (8192).
|
||||
longContent := string(make([]byte, maxInstructionContentLen+1))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Too Long",
|
||||
"content": longContent,
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(make([]byte, 201))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": longTitle,
|
||||
"content": "Short content.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "DB Error",
|
||||
"content": "This will fail.",
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_ValidPartial(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-update-1"
|
||||
newTitle := "Updated Title"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(&newTitle, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_AllFields(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-update-2"
|
||||
title := "Full Update"
|
||||
content := "New content body."
|
||||
priority := 20
|
||||
enabled := false
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": title,
|
||||
"content": content,
|
||||
"priority": priority,
|
||||
"enabled": enabled,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(&title, &content, &priority, &enabled, instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-too-long"
|
||||
longContent := string(make([]byte, maxInstructionContentLen+1))
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"content": longContent,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-title-long"
|
||||
longTitle := string(make([]byte, 201))
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": longTitle,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-missing"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": "New Title",
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-db-err"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": "Error Update",
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsDelete_Valid(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-delete-1"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = $1").
|
||||
WithArgs(instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-not-there"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = $1").
|
||||
WithArgs(instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-del-err"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("DELETE FROM platform_instructions WHERE id = $1").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-resolve-1"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols).
|
||||
AddRow("global", "Be Helpful", "Always help the user.").
|
||||
AddRow("global", "Stay on Topic", "Don't diverge.").
|
||||
AddRow("workspace", "Use Claude Code", "Claude Code is the default runtime.")
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out.WorkspaceID != wsID {
|
||||
t.Errorf("expected workspace_id %s, got %s", wsID, out.WorkspaceID)
|
||||
}
|
||||
// Global section must come before workspace section.
|
||||
if !bytes.Contains([]byte(out.Instructions), []byte("Platform-Wide Rules")) {
|
||||
t.Error("instructions should contain 'Platform-Wide Rules' section")
|
||||
}
|
||||
if !bytes.Contains([]byte(out.Instructions), []byte("Role-Specific Rules")) {
|
||||
t.Error("instructions should contain 'Role-Specific Rules' section")
|
||||
}
|
||||
// Global instructions must appear before workspace instructions.
|
||||
idxGlobal := bytes.Index([]byte(out.Instructions), []byte("Platform-Wide Rules"))
|
||||
idxWorkspace := bytes.Index([]byte(out.Instructions), []byte("Role-Specific Rules"))
|
||||
if idxGlobal >= idxWorkspace {
|
||||
t.Error("global section should appear before workspace section")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_EmptyWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-empty"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols)
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// No rows → builder writes nothing; empty string returned.
|
||||
if out.Instructions != "" {
|
||||
t.Errorf("expected empty instructions for empty workspace, got: %q", out.Instructions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-err"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/workspaces//instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: ""}}
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── scanInstructions edge cases ───────────────────────────────────────────────
|
||||
|
||||
// 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 ────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ContentExactlyAtLimit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
exactContent := string(make([]byte, maxInstructionContentLen))
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "At Limit",
|
||||
"content": exactContent,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "At Limit", exactContent, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("at-limit-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
// Exactly at limit must succeed (8192 chars is acceptable).
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 for content at limit, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── priority defaults ────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_PriorityDefaultsToZero(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
// Body omits priority — expect it defaults to 0.
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "No Priority",
|
||||
"content": "Default priority body.",
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "No Priority", "Default priority body.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("no-prio-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── nil scope_target for global instructions ─────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_GlobalScopeNilTarget(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Global Nil Target",
|
||||
"content": "Global instruction.",
|
||||
})
|
||||
|
||||
// For global scope, scope_target must be SQL NULL.
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Global Nil Target", "Global instruction.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("global-nil-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── workspace scope with empty string target (rejected) ─────────────────────
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeEmptyStringTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
empty := ""
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": empty,
|
||||
"title": "Empty Target",
|
||||
"content": "Empty workspace target.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for empty string scope_target, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve: scope label transitions ────────────────────────────────────────
|
||||
|
||||
func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-only-global"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols).
|
||||
AddRow("global", "Rule One", "First rule.").
|
||||
AddRow("global", "Rule Two", "Second rule.")
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// Two global instructions share one section header.
|
||||
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
|
||||
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-empty-update"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
// COALESCE(nil, ...) = unchanged; still updates updated_at.
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -91,11 +91,6 @@ 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 == "" {
|
||||
@@ -103,12 +98,7 @@ func loadWorkspaceEnv(orgBaseDir, filesDir string) map[string]string {
|
||||
}
|
||||
parseEnvFile(filepath.Join(orgBaseDir, ".env"), envVars)
|
||||
if filesDir != "" {
|
||||
// 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)
|
||||
parseEnvFile(filepath.Join(orgBaseDir, filesDir, ".env"), envVars)
|
||||
}
|
||||
return envVars
|
||||
}
|
||||
@@ -327,12 +317,6 @@ 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")
|
||||
@@ -349,18 +333,9 @@ 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 resolved != absRoot && !strings.HasPrefix(resolved, absRoot+string(filepath.Separator)) {
|
||||
if absJoined != absRoot && !strings.HasPrefix(absJoined, absRoot+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes root")
|
||||
}
|
||||
return absJoined, nil // return the lexical path, not the resolved one
|
||||
return absJoined, nil
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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,48 +78,6 @@ 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")
|
||||
|
||||
@@ -51,7 +51,6 @@ 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,
|
||||
@@ -536,12 +535,7 @@ class LangGraphA2AExecutor(AgentExecutor):
|
||||
# receive the error and stop polling.
|
||||
await updater.failed(
|
||||
message=new_text_message(
|
||||
# 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,
|
||||
f"Agent error: {e}", task_id=task_id, context_id=context_id
|
||||
)
|
||||
)
|
||||
finally:
|
||||
|
||||
@@ -47,7 +47,6 @@ 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
|
||||
@@ -322,8 +321,7 @@ async def tool_delegate_task(
|
||||
f"You should either: (1) try a different peer, (2) handle this task yourself, "
|
||||
f"or (3) inform the user that {peer_name} is unavailable and provide your best answer."
|
||||
)
|
||||
# OFFSEC-003: wrap peer result in trust boundary before returning to agent context
|
||||
return sanitize_a2a_result(result)
|
||||
return result
|
||||
|
||||
|
||||
async def tool_delegate_task_async(
|
||||
@@ -415,11 +413,7 @@ 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:
|
||||
# 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(matching[0])
|
||||
return json.dumps({"status": "not_found", "delegation_id": task_id})
|
||||
# Return all recent delegations
|
||||
summary = []
|
||||
@@ -428,9 +422,8 @@ async def tool_check_task_status(
|
||||
"delegation_id": d.get("delegation_id", ""),
|
||||
"target_id": d.get("target_id", ""),
|
||||
"status": d.get("status", ""),
|
||||
# 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", "")),
|
||||
"summary": d.get("summary", ""),
|
||||
"response_preview": d.get("response_preview", ""),
|
||||
})
|
||||
return json.dumps({"delegations": summary, "count": len(delegations)})
|
||||
except Exception as e:
|
||||
|
||||
@@ -40,16 +40,6 @@ 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
|
||||
|
||||
@@ -242,16 +232,10 @@ class GoogleADKA2AExecutor(AgentExecutor):
|
||||
type(exc).__name__,
|
||||
exc_info=True,
|
||||
)
|
||||
# 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))
|
||||
# Mirror sanitize_agent_error() convention: expose class name only.
|
||||
await event_queue.enqueue_event(
|
||||
new_text_message(f"Agent error: {type(exc).__name__}")
|
||||
)
|
||||
|
||||
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
||||
"""Cancel a running task — emits canceled state per A2A protocol."""
|
||||
|
||||
@@ -569,31 +569,9 @@ 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.
|
||||
|
||||
@@ -601,12 +579,10 @@ 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".
|
||||
|
||||
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.
|
||||
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()`.
|
||||
"""
|
||||
if category:
|
||||
tag = category
|
||||
@@ -614,13 +590,6 @@ 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."
|
||||
|
||||
|
||||
|
||||
@@ -668,31 +668,6 @@ 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
|
||||
|
||||
@@ -696,98 +696,6 @@ 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
|
||||
# ======================================================================
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,300 +0,0 @@
|
||||
"""Test coverage for shared_runtime helpers (issue #366).
|
||||
|
||||
Six helper functions previously had zero test coverage:
|
||||
_extract_part_text, extract_message_text, format_conversation_history,
|
||||
build_task_text, append_peer_guidance, brief_task
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from shared_runtime import (
|
||||
_extract_part_text,
|
||||
append_peer_guidance,
|
||||
brief_task,
|
||||
build_task_text,
|
||||
extract_message_text,
|
||||
format_conversation_history,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _extract_part_text
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractPartText:
|
||||
"""Coverage for shared_runtime._extract_part_text()."""
|
||||
|
||||
def test_dict_with_text_field(self):
|
||||
assert _extract_part_text({"text": "hello"}) == "hello"
|
||||
|
||||
def test_dict_without_text_field(self):
|
||||
assert _extract_part_text({"type": "image"}) == ""
|
||||
|
||||
def test_dict_with_empty_text_field(self):
|
||||
assert _extract_part_text({"text": ""}) == ""
|
||||
|
||||
def test_dict_with_root_nesting(self):
|
||||
"""Text buried in part['root']['text'] is extracted."""
|
||||
assert _extract_part_text({"root": {"text": "nested"}}) == "nested"
|
||||
|
||||
def test_dict_with_root_non_dict(self):
|
||||
"""part['root'] that is not a dict is safely skipped."""
|
||||
assert _extract_part_text({"root": "string", "text": "top"}) == "top"
|
||||
|
||||
def test_object_with_text_attribute(self):
|
||||
class FakePart:
|
||||
text = "attr-text"
|
||||
|
||||
assert _extract_part_text(FakePart()) == "attr-text"
|
||||
|
||||
def test_object_with_root_object_with_text(self):
|
||||
"""Object with root.attr.text is extracted (A2A v1 object style)."""
|
||||
|
||||
class FakeRoot:
|
||||
text = "root-attr-text"
|
||||
|
||||
class FakePart:
|
||||
root = FakeRoot()
|
||||
|
||||
assert _extract_part_text(FakePart()) == "root-attr-text"
|
||||
|
||||
def test_object_with_empty_text_attribute(self):
|
||||
class FakePart:
|
||||
text = ""
|
||||
|
||||
assert _extract_part_text(FakePart()) == ""
|
||||
|
||||
def test_none_input(self):
|
||||
assert _extract_part_text(None) == ""
|
||||
|
||||
def test_unexpected_type(self):
|
||||
"""Plain int/float/bool falls through to empty string."""
|
||||
assert _extract_part_text(42) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# extract_message_text
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractMessageText:
|
||||
"""Coverage for shared_runtime.extract_message_text()."""
|
||||
|
||||
def test_list_of_dict_parts(self):
|
||||
parts = [{"text": "hello"}, {"text": "world"}]
|
||||
assert extract_message_text(parts) == "hello world"
|
||||
|
||||
def test_single_part(self):
|
||||
assert extract_message_text([{"text": "single"}]) == "single"
|
||||
|
||||
def test_context_object_with_message_parts(self):
|
||||
"""RequestContext-like: .message.parts is the parts list."""
|
||||
|
||||
class FakeContext:
|
||||
class _Msg:
|
||||
parts = [{"text": "from context"}]
|
||||
|
||||
message = _Msg()
|
||||
|
||||
assert extract_message_text(FakeContext()) == "from context"
|
||||
|
||||
def test_context_object_without_message(self):
|
||||
"""No .message attr → falls back to treating input as a parts list."""
|
||||
|
||||
class FakeContext:
|
||||
pass # no .message
|
||||
|
||||
# Pass a list directly as the context-like object
|
||||
assert extract_message_text([{"text": "fallback"}]) == "fallback"
|
||||
|
||||
def test_whitespace_normalized(self):
|
||||
"""Leading/trailing whitespace is stripped; internal newlines are preserved."""
|
||||
parts = [{"text": " hello "}, {"text": "\nworld\n"}]
|
||||
result = extract_message_text(parts)
|
||||
# Leading/trailing stripped, but internal \n stays (join uses single space)
|
||||
assert result == "hello \nworld"
|
||||
assert not result.startswith(" ")
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_empty_parts_list(self):
|
||||
assert extract_message_text([]) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_conversation_history
|
||||
# =============================================================================
|
||||
|
||||
class TestFormatConversationHistory:
|
||||
"""Coverage for shared_runtime.format_conversation_history()."""
|
||||
|
||||
def test_single_user_message(self):
|
||||
hist = [("human", "hello")]
|
||||
out = format_conversation_history(hist)
|
||||
assert out == "User: hello"
|
||||
|
||||
def test_single_agent_message(self):
|
||||
hist = [("ai", "response")]
|
||||
out = format_conversation_history(hist)
|
||||
assert out == "Agent: response"
|
||||
|
||||
def test_interleaved_history(self):
|
||||
hist = [
|
||||
("human", "hello"),
|
||||
("ai", "hi there"),
|
||||
("human", "what is 2+2?"),
|
||||
("ai", "four"),
|
||||
]
|
||||
out = format_conversation_history(hist)
|
||||
lines = out.split("\n")
|
||||
assert lines[0] == "User: hello"
|
||||
assert lines[1] == "Agent: hi there"
|
||||
assert lines[2] == "User: what is 2+2?"
|
||||
assert lines[3] == "Agent: four"
|
||||
|
||||
def test_empty_history(self):
|
||||
assert format_conversation_history([]) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# build_task_text
|
||||
# =============================================================================
|
||||
|
||||
class TestBuildTaskText:
|
||||
"""Coverage for shared_runtime.build_task_text()."""
|
||||
|
||||
def test_no_history_returns_user_message_unchanged(self):
|
||||
assert build_task_text("do the thing", []) == "do the thing"
|
||||
|
||||
def test_history_prepends_transcript(self):
|
||||
hist = [("human", "hello"), ("ai", "hi")]
|
||||
result = build_task_text("follow-up", hist)
|
||||
assert "Conversation so far:" in result
|
||||
assert "User: hello" in result
|
||||
assert "Agent: hi" in result
|
||||
assert "follow-up" in result
|
||||
|
||||
def test_user_message_after_conversation_header(self):
|
||||
hist = [("human", "hello")]
|
||||
result = build_task_text("do it", hist)
|
||||
assert result.startswith("Conversation so far:")
|
||||
assert result.endswith("Current request: do it")
|
||||
|
||||
def test_empty_user_message_with_history(self):
|
||||
"""Empty user_message is still rendered with history."""
|
||||
hist = [("human", "hello")]
|
||||
result = build_task_text("", hist)
|
||||
assert "Conversation so far:" in result
|
||||
assert "Current request:" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# append_peer_guidance
|
||||
# =============================================================================
|
||||
|
||||
class TestAppendPeerGuidance:
|
||||
"""Coverage for shared_runtime.append_peer_guidance()."""
|
||||
|
||||
def test_base_text_appended(self):
|
||||
result = append_peer_guidance(
|
||||
"base text",
|
||||
peers_info="alpha: ws-1",
|
||||
default_text="default",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
assert result.startswith("base text")
|
||||
assert "## Peers" in result
|
||||
assert "alpha: ws-1" in result
|
||||
assert "Use delegate_task" in result
|
||||
|
||||
def test_null_base_text_uses_default(self):
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
peers_info="peer info",
|
||||
default_text="DEFAULT_TEXT",
|
||||
tool_name="tool",
|
||||
)
|
||||
assert result.startswith("DEFAULT_TEXT")
|
||||
|
||||
def test_whitespace_base_text_strips_to_empty_peers_still_added(self):
|
||||
"""Whitespace-only base_text is stripped but default_text is NOT used
|
||||
(only None triggers the fallback). The peers section is still appended."""
|
||||
result = append_peer_guidance(
|
||||
" ",
|
||||
peers_info="peer",
|
||||
default_text="DEF",
|
||||
tool_name="t",
|
||||
)
|
||||
# " ".strip() == ""; default_text is NOT substituted for whitespace
|
||||
assert "## Peers" in result
|
||||
assert "peer" in result
|
||||
assert "DEF" not in result # default_text only on None, not whitespace
|
||||
|
||||
def test_none_base_text_uses_default(self):
|
||||
"""None base_text triggers fallback to default_text."""
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
peers_info="peer",
|
||||
default_text="DEFAULT",
|
||||
tool_name="tool",
|
||||
)
|
||||
assert result.startswith("DEFAULT")
|
||||
assert "## Peers" in result
|
||||
|
||||
def test_empty_peers_info_skips_section(self):
|
||||
result = append_peer_guidance(
|
||||
"base",
|
||||
peers_info="",
|
||||
default_text="def",
|
||||
tool_name="tool",
|
||||
)
|
||||
# No "## Peers" section when peers_info is empty
|
||||
assert result == "base"
|
||||
|
||||
def test_whitespace_in_base_and_peers_normalized(self):
|
||||
result = append_peer_guidance(
|
||||
" base \n",
|
||||
peers_info=" peer-1 \n",
|
||||
default_text="def",
|
||||
tool_name="tool",
|
||||
)
|
||||
# Base should be stripped of leading/trailing whitespace
|
||||
assert result.startswith("base")
|
||||
# Peer info should be appended
|
||||
assert "peer-1" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# brief_task
|
||||
# =============================================================================
|
||||
|
||||
class TestBriefTask:
|
||||
"""Coverage for shared_runtime.brief_task()."""
|
||||
|
||||
def test_short_text_returned_unchanged(self):
|
||||
assert brief_task("hello", limit=60) == "hello"
|
||||
|
||||
def test_exact_limit_no_ellipsis(self):
|
||||
text = "A" * 60
|
||||
assert brief_task(text, limit=60) == text
|
||||
assert "..." not in text
|
||||
|
||||
def test_truncated_with_ellipsis(self):
|
||||
text = "A" * 80
|
||||
result = brief_task(text, limit=60)
|
||||
assert len(result) == 63 # 60 chars + "..."
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_limit_10_shortens(self):
|
||||
result = brief_task("hello world", limit=10)
|
||||
assert len(result) == 13 # 10 chars + "..."
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_limit_0_returns_ellipsis(self):
|
||||
"""limit=0 → 0-char slice + "..." since len("hello") > 0."""
|
||||
result = brief_task("hello", limit=0)
|
||||
assert result == "..."
|
||||
|
||||
def test_limit_1_single_char_plus_ellipsis(self):
|
||||
result = brief_task("hello", limit=1)
|
||||
assert len(result) == 4 # 1 char + "..."
|
||||
assert result.startswith("h")
|
||||
assert result.endswith("...")
|
||||
Reference in New Issue
Block a user