Compare commits

..

35 Commits

Author SHA1 Message Date
fullstack-engineer f153f9b350 fix(platform): /github-installation-token returns 501 on missing config (closes #388)
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Failing after 9s
audit-force-merge / audit (pull_request) Has been skipped
When GITHUB_APP_ID/INSTALLATION_ID are unset (post org suspension or
Gitea-canonical deployments without GitHub App), the fallback path in
GetInstallationToken was returning 500 Internal Server Error. This pollutes
platform logs with ~28×false-positive 500s/hour across all workspaces.

Return 501 Not Implemented with {"error":"GitHub integration not
configured","scm":"gitea"} when the "required" error fires — callers
can now distinguish "feature off" from "transient error" and stop polling.

Update TestGitHubToken_NoTokenProvider to assert the new 501 shape.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:13:27 +00:00
infra-sre c7d3f1345e fix(docker-compose): remove duplicate service definitions across include:
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
sop-tier-check / tier-check (pull_request) Failing after 10s
docker-compose.yml added `include: docker-compose.infra.yml` (commit 8cd52fc6)
to bootstrap infra services, but the duplicate postgres/redis/langfuse-db-init
definitions were NOT removed from the main file. Docker Compose v2 errors
out on duplicate service definitions between an include: directive and the
main file, so `docker compose build` currently fails with:

  services.langfuse-db-init conflicts with imported resource

Fix (per issue #377):
- docker-compose.yml: drop the now-redundant postgres, redis,
  langfuse-db-init, langfuse-clickhouse service definitions (SSOT is
  docker-compose.infra.yml via include:)
- docker-compose.infra.yml: add missing `networks: - molecule-core-net` and
  `restart: unless-stopped` to postgres/redis/langfuse-clickhouse so the
  services are fully configured when imported
- docker-compose.infra.yml: rename `clickhouse` → `langfuse-clickhouse`
  to match the service name already used in docker-compose.yml's langfuse
  service environment/depends_on blocks

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre b7c481b7bd ci: re-trigger after tier downgrade
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-sre d68200bb0b ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
core-be c92540ce68 [core-be-agent] fix(#354): wire delegation-results consumer into a2a executor
Close the A2A delegation auto-resume gap.

Root cause: heartbeat.py's _check_delegations already writes completed
delegation rows to DELEGATION_RESULTS_FILE and sends a self-message to
wake the agent. executor_helpers.read_delegation_results() was defined to
atomically consume that file, but a2a_executor._core_execute() never
called it — so delegation results were written but the agent never saw
them.

Fix: call read_delegation_results() at the top of _core_execute() and
prepend the results to the user input context so the agent can act on
them without an explicit check_task_status call. The Temporal durable
workflow path is also covered because it calls _core_execute() directly.

Test: two new cases — delegation results injected when file exists;
user input passed through unchanged when file is empty.

Closes molecule-core#354.
2026-05-11 05:11:37 +00:00
infra-sre d83df72b01 ci: re-trigger after label change
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-sre 5d437c87a4 ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
infra-runtime-be be0b3414ec test(workspace): add queue_id-absence and push-vs-poll distinction tests
Incorporates valuable extra coverage from fullstack-engineer's PR #336:
- test_push_queued_missing_queue_id_still_parsed: queue_id is optional,
  absence must not break parsing
- test_push_queued_is_distinct_from_poll_queued: both envelope shapes
  parse correctly and independently, with correct delivery_mode values

Also adds push_queued_no_queue_id fixture and regression gate entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-runtime-be 3a6ed4fc46 fix(workspace): push-mode Queued returns delivery_mode="push" (not silent default "poll")
Bug: a2a_response.py:197 returned Queued(method=method) without passing
delivery_mode, silently defaulting to "poll" for push-mode busy-queue
responses. Callers branching on v.delivery_mode would mis-identify push-mode
responses as poll-mode, causing wrong dispatch logic.

Fix: pass delivery_mode="push" explicitly in the push-mode branch.

Tests: add push_queued_full/notify/no_method fixtures and 4 test cases
asserting delivery_mode="push" for all three envelope shapes. Also add
adversarial {"queued": "yes"} and {"queued": False} → Malformed guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 6f1b9db2af ci: re-trigger after runner recovery
Co-Authored-By: infra-sre
2026-05-11 05:11:37 +00:00
hongming 16eab92e1e fix(ci): add _sanitize_a2a to TOP_LEVEL_MODULES allowlist (third workflow defect)
Run 5160 publish-runtime build step failed:

  error: TOP_LEVEL_MODULES drifted from workspace/*.py contents:
    in workspace/ but NOT in TOP_LEVEL_MODULES (will ship un-rewritten): ['_sanitize_a2a']
    Edit scripts/build_runtime_package.py:TOP_LEVEL_MODULES to match.

workspace/_sanitize_a2a.py was added recently but the allowlist in
scripts/build_runtime_package.py was not updated. The build script
intentionally aborts (exit 3) when it detects the drift, because
shipping a module un-rewritten breaks the package's flat-layout import
contract.

Fix: add '_sanitize_a2a' to the set. Alphabetical order preserved
(it sorts before 'a2a_*').

Third workflow defect after #353 (workflow_dispatch.inputs parser) and
#355 (Publish step working-directory). After this lands, attempt #4 of
runtime-v0.1.130 should finally succeed.

Refs: #351, #353, #355, #348 Q3
2026-05-11 05:11:37 +00:00
infra-sre 22263b1615 ci: re-trigger after runner recovery
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming fca6bcc43c fix(ci): add missing working-directory to publish-runtime Publish step
First-ever publish-runtime.yml dispatch (run 5097 post-#353, 2026-05-11
02:06Z) failed at the twine upload step:

  ERROR InvalidDistribution: Cannot find file (or expand pattern): 'dist/*'

Cause: the Publish step was missing 'working-directory: ${{ runner.temp
}}/runtime-build' while the preceding Build/Verify steps all had it.
Result: twine ran from the workspace checkout dir where dist/ doesn't
exist.

Fix: add working-directory to match the rest of the publish job.

This is the second of three workflow defects exposed by #353 finally
making the workflow run at all:
  1. workflow_dispatch.inputs rejection      → fixed in #353
  2. Publish step missing working-directory  → THIS PR
  3. (anything else surfaced by 0.1.130 attempt #2)

After merge: push runtime-v0.1.130 again (tag was already pushed once
post-#353 but the run failed at publish; need a fresh trigger). Should
finally land 0.1.130 on PyPI.

Refs: #351, #348 Q3, #353
2026-05-11 05:11:37 +00:00
core-devops fe1661e1cd fix(ci): add sqlalchemy>=2.0.0 to pip install step (closes #293)
test_audit_ledger.py imports sqlalchemy directly (line 42).
Without an explicit sqlalchemy install, pip dependency resolution can
omit it when pytest/pytest-asyncio/pytest-cov are installed as a
separate step after requirements.txt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 6af3a90e30 ci: re-trigger sop-tier-check after tier:low label
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre 8644e6c5e9 ci: remove .github/workflows/publish-workspace-server-image.yml duplicate
Gitea Actions reads .gitea/workflows/, not .github/workflows/. The
.github/ copy of this workflow has been kept in lockstep with .gitea/
since the post-suspension migration (e.g. 6d94fd30, 5216e781, 67b2e488
all touch both files). The functional code is identical between the
two; the only differences are comment verbosity and the path-filter
self-reference (each version watches its own location).

Removing the .github/ copy:
  - eliminates the dual-edit maintenance tax (two files touched per fix)
  - prevents accidental drift where one is updated and the other isn't
  - leaves a single source-of-truth at .gitea/workflows/

Cross-references confirmed safe:
  - canary-verify.yml + redeploy-tenants-on-{staging,main}.yml all use
    `workflows: ['publish-workspace-server-image']` (workflow name,
    not file path) — they trigger off the workflow_run event keyed on
    `name:`, which is identical in both files.
  - No other workflow path-watches .github/workflows/publish-workspace-
    server-image.yml.

Other two triplicates from task #287 (publish-runtime.yml and
secret-scan.yml) are NOT addressed in this PR — see PR description for
the ambiguity report flagging them for human review.

Refs: task #287

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre ebfee59a32 ci: re-trigger sop-tier-check after label + rebase
Trivial empty commit to force a fresh workflow run now that the
PR has tier:low label and approvals on the rebased branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
infra-sre ef5c3d2f46 fix(security): OFFSEC-003 — boundary-marker escape + shared sanitizer
Root cause (from infra-lead PR#7 review id=724):
Sanitization in PR#7 wrapped peer text in [A2A_RESULT_FROM_PEER]
markers, but the markers themselves were not escaped — a malicious
peer could inject "[/A2A_RESULT_FROM_PEER]" to close the trust
boundary early, making subsequent text appear inside the trusted zone.

Fix:
- Create workspace/_sanitize_a2a.py (leaf module, no circular import
  risk) with shared sanitize_a2a_result() + _escape_boundary_markers()
- _escape_boundary_markers() escapes boundary open/close markers in the
  raw peer text before wrapping (primary security control)
- Defense-in-depth: also escapes SYSTEM/OVERRIDE/INSTRUCTIONS/IGNORE
  ALL/YOU ARE NOW patterns (secondary, per PR#7 design intent)
- Update a2a_tools_delegation.py: import from _sanitize_a2a; wrap
  tool_delegate_task return and tool_check_task_status response_preview
- Add 15 tests covering boundary escape, injection patterns, integration
  shapes (workspace/tests/test_a2a_sanitization.py)

Follow-up (non-blocking, noted in PR#7 infra-lead review):
- Deduplicate if a2a_tools.py also wraps (currently handled in
  delegation module only — callers get sanitized output regardless)
- tool_check_task_status: consider sanitizing 'summary' field too

Closes: molecule-ai/molecule-ai-workspace-runtime#7 (wrong-repo PR
that this supersedes)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming 366cafef18 ci: re-trigger after 2026-05-10 actions/checkout auth-window stale failure 2026-05-11 05:11:37 +00:00
hongming 9e28ffd8d0 feat(canvas): mobile-first shell with 6-screen iOS design + responsive desktop fixes
Implements the Claude Design handoff (Molecules AI Mobile.html) as a
viewport-gated React tree under canvas/src/components/mobile/. < 640px
renders the new shell instead of the desktop ReactFlow canvas.

Six screens, all bound to live store data:
- Home (agent list + filter chips + spawn FAB)
- Canvas (mini-graph with pinch-to-zoom + pan + reset)
- Detail (status pills, tabs: Overview / Activity / Config / Memory;
  Activity hits /workspaces/:id/activity)
- Chat (textarea composer, IME-safe Enter, sendInFlightRef guard;
  bootstraps from agentMessages so the prior thread shows on entry)
- Comms (live A2A feed via /workspaces/:id/activity + ACTIVITY_LOGGED)
- Spawn (bottom sheet; fetches /templates so users pick what's actually
  installed on their platform)

Plus a Me tab for mobile theme/accent/density.

Design system (palette.ts + primitives.tsx) ports tokens 1:1 from the
handoff: cream + dark palettes, T1-T4 tier chips, status dots with
halo, JetBrains Mono for IDs/timestamps. Inter + JetBrains Mono are
self-hosted via next/font/google so CSP `font-src 'self'` is honoured.

URL routing: routes sync to ?m=<route>&a=<id>; popstate restores route;
deep links seed initial state. /?m=detail without ?a collapses to home.

Accent override flows through React context (MobileAccentProvider) —
not by mutating the static MOL_LIGHT/MOL_DARK singletons.

SSR flash: isMobile is tri-state; loading spinner stays up until
matchMedia resolves so mobile devices never paint the desktop tree.

Desktop responsiveness fixes (separate but ride along):
- Toolbar: full-width with overflow-x-auto on mobile, logo text + count
  hidden < sm, divider/border collapse to sm: only.
- SidePanel: full-screen on mobile via matchMedia, resize handle hidden.
- Canvas: MiniMap hidden < sm (was overlapping the New Workspace FAB).

Tests (51 total, 33 new):
- palette.test.ts (12) - normalizeStatus, tierCode, light/dark parity
- components.test.ts (10) - toMobileAgent field mapping + classifyForFilter
- MobileApp.test.tsx (12) - route stack, deep links, popstate, tab bar
  hidden on chat, spawn overlay
- SidePanel.tabs.test.tsx (18) - regression-clean

Verified: tsc --noEmit clean across mobile/, page.tsx, layout.tsx.
Not yet verified: live phone browser (needs CP backend hydrated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:11:37 +00:00
hongming 294fb18e18 fix(ci): remove workflow_dispatch.inputs (true root cause of #351 — Gitea parser rejects, workflow ignored)
ROOT CAUSE found in Gitea server logs:

  actions/workflows.go:DetectWorkflows() [W] ignore invalid workflow
  "publish-runtime.yml": unknown on type:
  map["version":{"description":...,"required":true,"type":"string"}]

Gitea 1.22.6's workflow parser flattens workflow_dispatch.inputs.* into
top-level 'on:' event-keys and rejects the workflow when it doesn't
recognize them. Once rejected, the workflow never registers — so NO
event triggers it. publish-runtime.yml has 0 runs in action_run since
the .gitea port for exactly this reason; the runtime-v1.0.0 tag from
yesterday and hongming-pc's runtime-v0.1.130 from tonight both pushed
successfully but went nowhere.

This supersedes the paths-vs-tags hypothesis from #351 (PR #352).
The split is still useful for clarity but was NOT the cause — even
the original tags-only port had this same parse failure.

Fix: drop the inputs block. workflow_dispatch in Gitea 1.22.6 supports
no-input dispatch only. The bash logic for version derivation now uses
just two cases: tag-push (strip prefix) or anything-else (PyPI auto-bump).

Post-merge verification:
  - watch for first-ever publish-runtime.yml run in action_run
  - check Gitea log no longer emits 'ignore invalid workflow' for this file
  - push a runtime-v0.1.130 tag → workflow fires → PyPI 0.1.130

Refs: #351 (root cause), #348 Q3 (the blocker)
2026-05-11 05:11:37 +00:00
hongming 738923c3fa fix(ci): split publish-runtime into tags-only + autobump (closes #351)
publish-runtime.yml has never fired since the .gitea port (0 rows in
action_run.workflow_id='publish-runtime.yml' ever), which is why PyPI
is still at 0.1.129 despite Gitea having a runtime-v1.0.0 tag.

Root cause hypothesis: Gitea Actions evaluates the on.push.paths filter
against tag-push events too (no path diff → workflow skipped). PR #349
made this visible by adding the paths trigger, but the same defect
existed for the originally-ported tags-only trigger on this Gitea version
— hence the runtime-v1.0.0 tag also never published.

Fix: split into two files, each with a single unambiguous trigger shape.

  - publish-runtime.yml          : on.push.tags only       (the publisher)
  - publish-runtime-autobump.yml : on.push.branches+paths  (NEW; the bumper)

The autobump file computes next version from PyPI latest, pushes
'runtime-v$VERSION' tag via DISPATCH_TOKEN (not GITHUB_TOKEN — needed
to trigger downstream workflows on Gitea), and exits. The tag push
then triggers publish-runtime.yml.

Test plan after merge:
  1. Push no-op commit to workspace/. Observe autobump fire, push tag.
  2. Observe publish-runtime.yml fire on the tag, publish 0.1.130 to
     PyPI, cascade to template repos.
  3. Verify 'action_run' shows >0 rows for both workflow_ids.
2026-05-11 05:11:37 +00:00
hongming 895cf737dc feat(ci): restore staging+main path-filter trigger on publish-runtime (closes #348 Q1)
Adds back the original GitHub workflow's auto-publish trigger that was
dropped during the 2026-05-10 .gitea port (#206). Push to main or
staging filtered by workspace/** falls into the existing PyPI-latest
auto-bump path — no logic changes, just the missing trigger and a
comment correction.

Caveat: the workflow still requires PYPI_TOKEN as a repository secret
(or org-level). Without it the publish step will fail loudly with a
descriptive error. Q2 follow-up tracks setting the secret.

Refs: molecule-core#348
2026-05-11 05:11:37 +00:00
core-devops b1b5c67055 fix(ci): install jq before sop-tier-check script runs
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
Root cause: the sop-tier-check.sh script uses jq extensively for all
JSON API parsing (whoami, labels, team IDs, reviews). Gitea Actions
runners (ubuntu-latest label) do not bundle jq — script exits at
line 67 with "jq: command not found", producing "Failing after 1-3s"
status on every staging PR.

Fix: add apt-get install -y jq step before the script run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 03:35:47 +00:00
core-be de5d8585c7 Merge pull request 'fix(platform): A2A proxy ResponseHeaderTimeout 60s → 180s default, env-configurable' (#322) from fix/a2a-proxy-response-header-timeout-clean into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-11 01:34:44 +00:00
fullstack-engineer 6958cd7966 Merge pull request 'fix(workspace): inject plugins_registry into sys.modules before loading adapters (closes #296)' (#326) from fix/issue-296-plugin-registry-sysmodules into staging
Secret scan / Scan diff for credential-shaped strings (push) Successful in 3s
2026-05-10 21:14:10 +00:00
fullstack-engineer ba0680d5fb fix(platform): A2A proxy ResponseHeaderTimeout 60s → 180s default, env-configurable
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 2s
sop-tier-check / tier-check (pull_request) Failing after 1s
audit-force-merge / audit (pull_request) Successful in 3s
Cherry-pick of d79a4bd2 from PR #318 onto fresh main base (PR #318 closed).

Issue #310: platform a2a-proxy logs ~300/hr
`timeout awaiting response headers` because ResponseHeaderTimeout was hardcoded
to 60s. Opus agent turns (big context + internal delegate_task round-trips)
routinely exceed 60s, so the proxy gave up before headers arrived even when
the workspace agent was healthy.

Changes:
- a2a_proxy.go: ResponseHeaderTimeout: 60s hardcoded →
  envx.Duration("A2A_PROXY_RESPONSE_HEADER_TIMEOUT", 180s).
  180s gives Opus turns comfortable headroom. The X-Timeout caller header
  still bounds the absolute request ceiling independently.
- a2a_proxy_test.go: TestA2AClientResponseHeaderTimeout verifies the 180s
  default and env-override parsing logic.

Env var: A2A_PROXY_RESPONSE_HEADER_TIMEOUT (e.g. 5m, 300s).

Closes #310.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 14:47:56 +00:00
fullstack-engineer d4d3306150 fix(workspace): inject plugins_registry into sys.modules before loading adapters (closes #296)
sop-tier-check / tier-check (pull_request) Failing after 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 58s
audit-force-merge / audit (pull_request) Successful in 2s
Plugin adapters in molecule-skill-* repos do:
  from plugins_registry.builtins import AgentskillsAdaptor as Adaptor

But _load_module_from_path() used exec_module() with a fresh module
namespace that did NOT have plugins_registry or its submodules in sys.modules,
causing:
  ModuleNotFoundError: No module named 'plugins_registry'

Fix: before exec_module(), import and register plugins_registry + all three
submodules (builtins, protocol, raw_drop) in sys.modules so adapter imports
resolve correctly.  Follows the Option 1 recommendation from issue #296.

Also adds test_resolve_plugin.py verifying the fix for both the
AgentskillsAdaptor import and the full InstallContext/resolve/protocol import.

Closes #296.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 14:17:16 +00:00
core-devops a3c9f0b717 Merge pull request 'ci: pin GitHub Actions by SHA instead of mutable tags (staging sync)' (#276) from ci/staging-sha-pinning into staging
Secret scan / Scan diff for credential-shaped strings (push) Failing after 2s
2026-05-10 14:03:05 +00:00
infra-lead de9f46ea30 Merge pull request '[release-blocker] fix(ci): retry git clone in clone-manifest.sh (publish-workspace-server-image OOM flake)' (#298) from fix/publish-workspace-server-ci-clone-manifest-retry into staging
Secret scan / Scan diff for credential-shaped strings (push) Waiting to run
2026-05-10 12:44:35 +00:00
infra-lead 7ff5622a42 [infra-lead-agent] fix(ci): retry git clone in clone-manifest.sh (publish-workspace-server-image flake)
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Failing after 1s
audit-force-merge / audit (pull_request) Failing after 2s
The publish-workspace-server-image / build-and-push job clones the full
manifest (~36 repos) serially in the "Pre-clone manifest deps" step on a
memory-constrained Gitea Actions runner. Under host memory pressure the
OOM killer SIGKILLs git-remote-https mid-clone:

  cloning .../molecule-ai-plugin-molecule-skill-code-review.git ...
  error: git-remote-https died of signal 9
  fatal: the remote end hung up unexpectedly
    Failure - Main Pre-clone manifest deps
  exitcode '128': failure

Observed in run 4622 (2026-05-10, staging HEAD b5d2ab88) — died on the
14th of 36 clones, which red-lights CI and wedges staging→main.

Wrap each `git clone` in clone-manifest.sh with bounded retry + backoff
(3 attempts, 3s/6s), wiping any partial checkout between tries. A single
transient SIGKILL / network blip no longer fails the whole tenant image
rebuild. Benefits every caller of the script (publish-workspace-server-image,
harness-replays, Dockerfile builds, local quickstart).

This is a mitigation; the durable fix is more runner RAM/swap on the
operator host — tracked separately with Infra-SRE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:58:09 +00:00
fullstack-engineer bea89ce4e9 fix(a2a): handle string-form errors in delegate_task
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 14s
sop-tier-check / tier-check (pull_request) Failing after 7s
audit-force-merge / audit (pull_request) Failing after 5s
The A2A proxy can return three error shapes:
  {"error": "plain string"}
  {"error": {"message": "...", "code": ...}}
  {"error": {"message": {"nested": "object"}}}   ← value at .message is a string

builtin_tools/a2a_tools.py:72 called data["error"].get("message")
without guarding against error being a string, which raised:
  AttributeError: 'str' object has no attribute 'get'

This broke every delegation attempt through the legacy a2a_tools path
(the LangChain-wrapped version used by adapter templates). The
SSOT parser a2a_response.py already handled string errors; the
legacy inline sniffer in a2a_tools.py did not.

Fix: branch on isinstance(err, dict/str/other) before calling .get().

Also update both publish-workflow files to remove the dead
`staging` branch trigger — trunk-based migration (PR #109,
2026-05-08) removed the staging branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 11:39:32 +00:00
integration-tester 14f05b5a64 chore: restore manifest.json after trigger test 2026-05-10 11:38:34 +00:00
integration-tester 7caee806df chore: trigger publish workflow [Integration Tester 2026-05-10T08:45Z] 2026-05-10 11:38:34 +00:00
integration-tester a914f675a4 chore: staging trigger commit from Integration Tester 2026-05-10 11:38:34 +00:00
11 changed files with 151 additions and 101 deletions
@@ -32,11 +32,9 @@ on:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
# Serialize per-branch so two rapid main pushes don't race the same
# :staging-latest tag retag. Allow parallel runs as they produce
# different :staging-<sha> tags and last-write-wins on :staging-latest.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
+7
View File
@@ -77,6 +77,13 @@ jobs:
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Install jq
# Gitea Actions runners (ubuntu-latest label) do not bundle jq.
# The script uses jq extensively for all JSON parsing; install it
# before the script runs. Using -qq for quiet output — diagnostic
# info is already captured via SOP_DEBUG=1 on failure.
run: apt-get update -qq && apt-get install -y -qq jq
- name: Verify tier label + reviewer team membership
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
+1
View File
@@ -0,0 +1 @@
staging trigger
+16 -4
View File
@@ -11,6 +11,9 @@ services:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
@@ -25,6 +28,8 @@ services:
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
networks:
- molecule-core-net
command:
- /bin/sh
- -c
@@ -45,6 +50,9 @@ services:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
@@ -52,7 +60,9 @@ services:
retries: 10
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
clickhouse:
# Named langfuse-clickhouse (not clickhouse) to match the service name used in
# docker-compose.yml's depends_on block for the main langfuse service.
langfuse-clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
@@ -60,6 +70,8 @@ services:
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
@@ -104,7 +116,7 @@ services:
langfuse-web:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
depends_on:
clickhouse:
langfuse-clickhouse:
condition: service_healthy
langfuse-db-init:
condition: service_completed_successfully
@@ -113,8 +125,8 @@ services:
# Langfuse v2 expects the HTTP interface (port 8123). The previous
# clickhouse://...:9000 native-protocol URL is rejected with
# "ClickHouse URL protocol must be either http or https".
CLICKHOUSE_URL: http://clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-langfuse-dev}
NEXTAUTH_SECRET: ${LANGFUSE_SECRET:-changeme-langfuse-secret}
+1 -79
View File
@@ -3,85 +3,7 @@ include:
- docker-compose.infra.yml
services:
# --- Infrastructure ---
# digest-pinned 2026-05-10 (sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579, linux/amd64)
postgres:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
POSTGRES_DB: ${POSTGRES_DB:-molecule}
command: ["postgres", "-c", "wal_level=logical"]
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-dev}"]
interval: 2s
timeout: 5s
retries: 10
langfuse-db-init:
image: postgres@sha256:4941ef97aaa2633ce9808f7766f8b8d746dd039ce8c51ca6da185c3dc63ab579
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_USER: ${POSTGRES_USER:-dev}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev}
command:
- /bin/sh
- -c
- |
export PGPASSWORD="$${POSTGRES_PASSWORD}"
until pg_isready -h postgres -U "$${POSTGRES_USER}" -d postgres >/dev/null 2>&1; do
sleep 1
done
if ! psql -h postgres -U "$${POSTGRES_USER}" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = 'langfuse'" | grep -q 1; then
psql -h postgres -U "$${POSTGRES_USER}" -d postgres -c "CREATE DATABASE langfuse"
fi
networks:
- molecule-core-net
# digest-pinned 2026-05-10 (sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7, linux/amd64)
redis:
image: redis@sha256:b1addbe72465a718643cff9e60a58e6df1841e29d6d7d60c9a85d8d72f08d1a7
command: ["redis-server", "--notify-keyspace-events", "KEA"]
ports:
- "6379:6379"
volumes:
- redisdata:/data
networks:
- molecule-core-net
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 5s
retries: 10
# --- Observability ---
# digest-pinned 2026-05-10 (sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe, linux/amd64)
langfuse-clickhouse:
image: clickhouse/clickhouse-server@sha256:5b296e0ba1da74efea3143c773ddd60245f249fb7c72eb1d866c2d6ebc759fbe
environment:
CLICKHOUSE_DB: langfuse
CLICKHOUSE_USER: langfuse
CLICKHOUSE_PASSWORD: langfuse
volumes:
- clickhousedata:/var/lib/clickhouse
networks:
- molecule-core-net
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 10
# digest-pinned 2026-05-10 (sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d, linux/amd64)
langfuse:
image: langfuse/langfuse@sha256:e7aafd3ccf721821b40f8b2251220b4bb8af5e4877b5c5a8846af5b3318aaf1d
@@ -295,7 +217,7 @@ services:
- "4000:4000"
volumes:
- ./infra/litellm_config.yml:/app/config.yaml:ro
command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", "4"]
command: ["--config", "/app/config.yaml", "--port", "4000", "--num_workers", 4]
environment:
# Pass provider API keys through — only the ones you have are needed
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
+1
View File
@@ -44,3 +44,4 @@
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
]
}
// Triggered by Integration Tester at 2026-05-10T08:52Z
@@ -49,6 +49,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
@@ -98,7 +99,19 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
token, expiresAt, err := generateAppInstallationToken()
if err != nil {
log.Printf("[github] fallback token generation failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
// #388: when GITHUB_APP_ID/INSTALLATION_ID are unset (e.g. post
// org suspension or Gitea-canonical deployments), this is a
// configuration gap, not an internal server error. Return 501 so
// callers (workspace polling loop) can distinguish "feature off"
// from "transient error" and stop polling.
if strings.Contains(err.Error(), "required") {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "GitHub integration not configured",
"scm": "gitea",
})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
}
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
@@ -76,14 +76,16 @@ func TestGitHubToken_NilRegistry(t *testing.T) {
// implement TokenProvider (e.g. a non-GitHub mutator in the chain).
//
// Post-#960/#1101 the handler now falls back to direct env-based App
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE)
// when no registered provider matches. In the test environment those
// env vars are unset, so the fallback fails with 500 "token refresh
// failed" — a clean retryable signal for the workspace credential
// helper. Previously this path returned 404; the new 500 matches the
// ProviderError shape so callers don't have to branch on "missing
// provider" vs "provider failed".
func TestGitHubToken_NoTokenProvider(t *testing.T) {
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE).
//
// When GITHUB_APP_ID or INSTALLATION_ID is unset (e.g. post org suspension
// or Gitea-canonical deployments without GitHub App), generateAppInstallationToken
// returns an error with "required" in the message. The handler now returns
// 501 Not Implemented with {"error":"GitHub integration not configured","scm":"gitea"}
// so callers can distinguish "feature off" from "transient error" and stop
// polling (#388). Other errors (e.g. network failures reading the private key)
// still return 500.
func TestGitHubToken_NoTokenProvider_MissingConfigReturns501(t *testing.T) {
reg := provisionhook.NewRegistry()
reg.Register(&mockMutatorOnly{name: "other-plugin"})
h := NewGitHubTokenHandler(reg)
@@ -91,12 +93,20 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
h.GetInstallationToken(c)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
// GITHUB_APP_ID/INSTALLATION_ID are unset in test env → "required" error → 501
if w.Code != http.StatusNotImplemented {
t.Fatalf("expected 501 for missing GITHUB_APP_ID/INSTALLATION_ID, got %d: %s",
w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "token refresh failed") {
t.Errorf("expected body to contain 'token refresh failed', got: %s", w.Body.String())
var body map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if body["error"] == "" {
t.Error("expected non-empty error field in 501 response")
}
if body["scm"] != "gitea" {
t.Errorf("expected scm=gitea, got %q", body["scm"])
}
}
+10
View File
@@ -77,6 +77,16 @@ async def delegate_task(workspace_id: str, task: str) -> str:
return str(result) if isinstance(result, str) else "(no text)"
elif "error" in data:
err = data["error"]
# Handle both string-form errors ("error": "some string")
# and object-form errors ("error": {"message": "...", "code": ...}).
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
elif isinstance(err, str):
msg = err
else:
msg = str(err)
return f"Error: {msg}"
msg = ""
if isinstance(err, dict):
msg = err.get("message", "")
+16
View File
@@ -51,6 +51,22 @@ class AdaptorSource:
def _load_module_from_path(module_name: str, path: Path):
"""Import a Python file by absolute path. Returns the module or None on failure."""
# Ensure the plugins_registry package and its submodules are importable in the
# fresh module namespace created by module_from_spec(). Plugin adapters
# (molecule-skill-*/adapters/*.py) use "from plugins_registry.builtins import ..."
# which requires plugins_registry and its submodules to already be in sys.modules.
# We import and register them before exec_module so the plugin's own
# from ... import statements resolve correctly.
import sys
import plugins_registry
sys.modules.setdefault("plugins_registry", plugins_registry)
for _sub in ("builtins", "protocol", "raw_drop"):
try:
sub = importlib.import_module(f"plugins_registry.{_sub}")
sys.modules.setdefault(f"plugins_registry.{_sub}", sub)
except Exception:
# Submodule may not exist in all versions; skip if absent.
pass
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
return None
@@ -0,0 +1,60 @@
"""Tests for _load_module_from_path sys.modules injection fix (issue #296).
Verifies that plugin adapters using "from plugins_registry.builtins import ..."
can be loaded via _load_module_from_path() without ModuleNotFoundError.
"""
import sys
import tempfile
import os
from pathlib import Path
# Ensure the plugins_registry package is importable
import plugins_registry
from plugins_registry import _load_module_from_path
def test_load_adapter_with_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry.builtins import ...' loads cleanly."""
# Write a temp adapter file that does the exact import from the bug report.
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry.builtins import AgentskillsAdaptor as Adaptor\n")
f.write("assert Adaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "Adaptor"), "module should expose Adaptor"
finally:
os.unlink(adapter_path)
def test_load_adapter_with_full_plugins_registry_import():
"""Plugin adapter using 'from plugins_registry import ...' loads cleanly."""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".py", delete=False, dir=tempfile.gettempdir()
) as f:
f.write("from plugins_registry import InstallContext, resolve\n")
f.write("from plugins_registry.protocol import PluginAdaptor\n")
f.write("assert InstallContext is not None\n")
f.write("assert resolve is not None\n")
f.write("assert PluginAdaptor is not None\n")
adapter_path = Path(f.name)
try:
module = _load_module_from_path("test_adapter_full", adapter_path)
assert module is not None, "module should load without error"
assert hasattr(module, "InstallContext"), "module should expose InstallContext"
assert hasattr(module, "resolve"), "module should expose resolve"
assert hasattr(module, "PluginAdaptor"), "module should expose PluginAdaptor"
finally:
os.unlink(adapter_path)
if __name__ == "__main__":
test_load_adapter_with_plugins_registry_import()
test_load_adapter_with_full_plugins_registry_import()
print("ALL TESTS PASS")