@openhands/extensions 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/.claude-plugin/marketplace.json +24 -0
  2. package/.codex-plugin/marketplace.json +24 -0
  3. package/.github/pull_request_template.md +17 -2
  4. package/.github/scripts/check_pr_description.py +64 -5
  5. package/.github/scripts/markdown_sections.py +95 -0
  6. package/.github/workflows/build-vscode-app.yml +30 -0
  7. package/.github/workflows/check-extensions.yml +3 -0
  8. package/.github/workflows/tests.yml +26 -0
  9. package/.plugin/marketplace.json +24 -0
  10. package/.release-please-manifest.json +1 -1
  11. package/AGENTS.md +3 -2
  12. package/README.md +9 -3
  13. package/apps/README.md +20 -0
  14. package/apps/catalog.json +10 -0
  15. package/apps/index.d.ts +11 -0
  16. package/apps/index.js +5 -0
  17. package/apps/vscode/README.md +13 -0
  18. package/apps/vscode/canvas-extension.json +19 -0
  19. package/apps/vscode/extension.js +114 -0
  20. package/apps/vscode/scripts/package.mjs +84 -0
  21. package/automations/bundle-index.js +15 -15
  22. package/automations/catalog/github-agents-md-maintainer/manifest.json +1 -1
  23. package/automations/catalog/github-pr-reviewer/manifest.json +5 -5
  24. package/automations/catalog/github-stale-ci-pr-closer/manifest.json +3 -3
  25. package/automations/catalog/gitlab-issue-to-mr/manifest.json +1 -1
  26. package/automations/catalog/news-digest/manifest.json +2 -2
  27. package/index.d.ts +2 -0
  28. package/index.js +1 -0
  29. package/marketplaces/openhands-extensions.json +24 -0
  30. package/package.json +9 -1
  31. package/plugins/openhands/skills/openhands-api/README.md +1 -1
  32. package/plugins/openhands/skills/openhands-api/SKILL.md +3 -3
  33. package/plugins/openhands/skills/openhands-api/scripts/openhands_api.py +13 -0
  34. package/plugins/openhands/skills/openhands-automation/SKILL.md +66 -4
  35. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +21 -7
  36. package/plugins/runtime-api-configs/.claude-plugin/plugin.json +20 -0
  37. package/plugins/runtime-api-configs/.codex-plugin/plugin.json +20 -0
  38. package/plugins/runtime-api-configs/.plugin/plugin.json +20 -0
  39. package/plugins/runtime-api-configs/README.md +59 -0
  40. package/plugins/runtime-api-configs/SKILL.md +240 -0
  41. package/plugins/runtime-api-configs/scripts/warm_runtime_configs.py +331 -0
  42. package/plugins/runtime-api-configs/tests/test_warm_runtime_configs.py +338 -0
  43. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +223 -43
  44. package/plugins/vulnerability-remediation/skills/github/scripts/github_client.py +67 -15
  45. package/pyproject.toml +1 -1
  46. package/python/openhands_extensions/_version.py +1 -1
  47. package/scripts/check_plugin_resources.py +116 -0
  48. package/scripts/sync_extensions.py +32 -5
  49. package/skills/canvas-extension-api/SKILL.md +34 -3
  50. package/skills/canvas-extension-api/references/backend-safety.md +14 -0
  51. package/skills/canvas-extension-api/references/connections.md +24 -0
  52. package/skills/canvas-extension-api/references/sidecar-pattern.md +49 -10
  53. package/skills/canvas-extension-api/references/v1-contract.md +53 -3
  54. package/skills/canvas-extension-api/scripts/validate-extension.mjs +52 -0
  55. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +192 -1
  56. package/skills/github/scripts/agent_conversation.py +223 -43
  57. package/skills/github/scripts/github_client.py +67 -15
  58. package/skills/github-agents-md-maintainer/README.md +7 -0
  59. package/skills/github-agents-md-maintainer/SKILL.md +13 -6
  60. package/skills/github-agents-md-maintainer/scripts/github_client.py +67 -15
  61. package/skills/github-agents-md-maintainer/scripts/main.py +199 -29
  62. package/skills/github-delivery-watchdog/scripts/github_client.py +67 -15
  63. package/skills/github-issue-to-pr/scripts/agent_conversation.py +223 -43
  64. package/skills/github-issue-to-pr/scripts/github_client.py +67 -15
  65. package/skills/github-issue-to-pr/scripts/worker.py +10 -1
  66. package/skills/github-issue-triage/scripts/agent_conversation.py +223 -43
  67. package/skills/github-issue-triage/scripts/github_client.py +67 -15
  68. package/skills/github-pr-reviewer/README.md +5 -4
  69. package/skills/github-pr-reviewer/SKILL.md +8 -17
  70. package/skills/github-pr-reviewer/references/state-schema.md +0 -15
  71. package/skills/github-pr-reviewer/scripts/agent_conversation.py +223 -43
  72. package/skills/github-pr-reviewer/scripts/github_client.py +67 -15
  73. package/skills/github-pr-reviewer/scripts/worker.py +131 -108
  74. package/skills/github-repo-monitor/scripts/github_client.py +67 -15
  75. package/skills/github-stale-ci-pr-closer/scripts/worker.py +223 -50
  76. package/skills/github-stale-ci-pr-closer/tests/test_worker.py +155 -15
  77. package/skills/gitlab-issue-to-mr/README.md +12 -0
  78. package/skills/gitlab-issue-to-mr/SKILL.md +13 -6
  79. package/skills/gitlab-issue-to-mr/scripts/main.py +217 -42
  80. package/skills/incident-retrospective/SKILL.md +5 -5
  81. package/skills/index.js +23 -14
  82. package/skills/jira-issue-to-pr/README.md +12 -4
  83. package/skills/jira-issue-to-pr/SKILL.md +129 -18
  84. package/skills/jira-issue-to-pr/references/setup.md +8 -0
  85. package/skills/jira-issue-to-pr/scripts/main.py +181 -54
  86. package/skills/jira-issue-to-pr/scripts/setup_webhook.py +290 -0
  87. package/skills/linear-triage/SKILL.md +5 -5
  88. package/skills/news-digest/README.md +6 -0
  89. package/skills/news-digest/SKILL.md +19 -9
  90. package/skills/news-digest/commands/news-digest-setup.md +1 -1
  91. package/skills/news-digest/scripts/main.py +133 -15
  92. package/skills/openhands-api/README.md +1 -1
  93. package/skills/openhands-api/SKILL.md +3 -3
  94. package/skills/openhands-api/scripts/openhands_api.py +13 -0
  95. package/skills/openhands-automation/SKILL.md +66 -4
  96. package/skills/openhands-automation/references/custom-automation.md +21 -7
  97. package/skills/research-brief/SKILL.md +5 -5
  98. package/skills/slack-standup-digest/SKILL.md +5 -5
  99. package/skills/ticket-to-code-change/SKILL.md +6 -3
  100. package/skills/use-jev/.claude-plugin/plugin.json +8 -0
  101. package/skills/use-jev/.codex-plugin/plugin.json +8 -0
  102. package/skills/use-jev/.plugin/plugin.json +8 -0
  103. package/skills/use-jev/README.md +66 -0
  104. package/skills/use-jev/SKILL.md +66 -0
  105. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  106. package/tests/test_agent_conversation_dispatch.py +212 -0
  107. package/tests/test_apps_catalog.py +26 -0
  108. package/tests/test_automation_setup.py +44 -0
  109. package/tests/test_check_pr_description.py +89 -6
  110. package/tests/test_github_reviewer_delivery.py +254 -118
  111. package/tests/test_github_stale_ci_pr_closer.py +56 -0
  112. package/tests/test_jira_issue_to_pr.py +312 -0
  113. package/tests/test_jira_setup_webhook.py +309 -0
  114. package/tests/test_openhands_api_python.py +37 -0
  115. package/tests/test_skill_plugin_loading.py +25 -15
  116. package/tests/test_sync_extensions.py +102 -0
  117. package/tests/test_vscode_app.py +33 -0
  118. package/tests/vscode-app.test.mjs +43 -0
@@ -27,11 +27,6 @@ from maintainer_handoff import (
27
27
  # every current-head check and workflow run, so a red head still blocks.
28
28
  CHECK_GATE_MARKER = "<!-- openhands-review-gate:"
29
29
  NON_BLOCKING_CHECK_CONCLUSIONS = frozenset({"success", "neutral", "skipped"})
30
- # How many unrequested heads one scheduled scan examines, per repository. Each
31
- # one costs a review read plus the exact-head check/workflow reads, so an
32
- # unbounded scan spends the whole run's API budget in the largest repository.
33
- # The window rotates by this many PRs per scan, so the backlog is still covered.
34
- SCAN_WINDOW = 10
35
30
  # The gate is deterministic; this disclosure is what tells a reader no model ran.
36
31
  WORKFLOW_DISCLOSURE = "no AI was used to generate this comment"
37
32
 
@@ -112,56 +107,6 @@ class ReviewIntake:
112
107
  )
113
108
 
114
109
 
115
- class ScanCursor:
116
- """The per-repository position of the rotating unrequested-PR window.
117
-
118
- Classifying one unrequested head costs a review read, a check-run read, and
119
- a workflow-run read, so a scan that examined every open PR spent the API
120
- budget on the largest repository alone. The scan instead examines a bounded
121
- slice of the unrequested backlog and remembers where the slice ended, in the
122
- automation service's own KV store - the same facility `main.py` already uses
123
- for review state, reached with the same `AUTOMATION_KV_TOKEN` and
124
- `AUTOMATION_API_URL`, so no new secret or store is introduced. The next scan
125
- resumes past that point, so the whole backlog is covered fairly over several
126
- scans.
127
-
128
- When the KV store is unavailable (a local run, or the tests) the position is
129
- kept in memory, so the scan still rotates within the run and nothing else
130
- about it changes. A KV read or write failure is not fatal: a scan position is
131
- not worth aborting reviews over, so the in-memory position is used instead.
132
- """
133
-
134
- def __init__(self, repository):
135
- self._key = f"review-scan:{workflow._repo_slug(repository)}"
136
- self._memory = 0
137
-
138
- def position(self, total):
139
- """The stored cursor, reduced to a valid offset into `total` items."""
140
- return self._read() % total if total else 0
141
-
142
- def advance(self, cursor):
143
- """Record where the next scan's window starts."""
144
- self._memory = cursor
145
- if not workflow._kv_available():
146
- return
147
- try:
148
- workflow._kv_set(self._key, {"cursor": cursor})
149
- except Exception as exc: # noqa: BLE001 - a cursor is not worth failing a scan
150
- print(f" Warning: scan cursor write failed ({exc})")
151
-
152
- def _read(self):
153
- if not workflow._kv_available():
154
- return self._memory
155
- try:
156
- data = workflow._kv_get(self._key) or {}
157
- except Exception as exc: # noqa: BLE001 - fall back to the in-memory cursor
158
- print(f" Warning: scan cursor read failed ({exc})")
159
- return self._memory
160
- try:
161
- return int(data.get("cursor") or 0)
162
- except (TypeError, ValueError):
163
- return self._memory
164
-
165
110
 
166
111
  class PullRequestReviewer(GitHubRepository):
167
112
  name = "github-pr-reviewer"
@@ -315,6 +260,16 @@ class PullRequestReviewer(GitHubRepository):
315
260
  "- Inspect the current GitHub Actions results for the exact head and run "
316
261
  "the repository's appropriate focused tests in the workspace. Do not "
317
262
  "modify tracked files.\n"
263
+ "- Before publishing anything, read the PR's existing reviews and "
264
+ "comments on GitHub. If this account has already published a review on "
265
+ f"`{sha}` and no one else has commented since that review, do NOT "
266
+ "publish a second review and do NOT start another review pass: another "
267
+ "review of identical code is a duplicate. Report the earlier verdict as "
268
+ "still current and stop. Review the head again only when the head has "
269
+ "moved, or when a comment from someone other than this account was "
270
+ "posted after the earlier review.\n"
271
+ "- If a review by this account is currently underway for this head, wait "
272
+ "for it instead of starting a second one.\n"
318
273
  f"- Re-read {self.repository} PR #{number} immediately before reporting. "
319
274
  "Confirm its current body, labels, review threads, and the head's check "
320
275
  "results, and re-read every linked issue's current body and labels: an "
@@ -745,7 +700,7 @@ class PullRequestReviewer(GitHubRepository):
745
700
  `explain` is False for an unrequested candidate: a PR nobody asked about
746
701
  that is merely red or pending gets no managed comment. Announcing a
747
702
  blocked or waiting head for every open PR is what produced the comment
748
- storm the rotating window is bounded against, and the managed comment is
703
+ storm that full-backlog scans must avoid, and the managed comment is
749
704
  the answer to an explicit request or trigger label. The gate still
750
705
  classifies the head, so a red or pending unrequested head is skipped
751
706
  without starting an agent.
@@ -765,14 +720,12 @@ class PullRequestReviewer(GitHubRepository):
765
720
  return state, sha
766
721
 
767
722
  def _outstanding_review_request(self, pr):
768
- """Whether an open, non-draft PR still holds a request for the reviewer.
723
+ """Whether an open PR, including a draft, still requests this reviewer.
769
724
 
770
725
  The list endpoint already answers this: `requested_reviewers` is the live
771
726
  set, so a review that was submitted, or a request that was withdrawn, is
772
- simply absent. Drafts are excluded because a draft is not reviewable.
727
+ simply absent. An explicit request opts a draft into review.
773
728
  """
774
- if pr.get("draft"):
775
- return False
776
729
  return any(
777
730
  (item.get("login") or "").lower() == self.trigger_reviewer
778
731
  for item in pr.get("requested_reviewers") or []
@@ -794,6 +747,66 @@ class PullRequestReviewer(GitHubRepository):
794
747
  for review in self.gh_pages(f"/pulls/{number}/reviews")
795
748
  )
796
749
 
750
+ def _clarified_since(self, number, submitted_at):
751
+ """Whether a human clarified the pull request after a review.
752
+
753
+ A comment by someone other than the reviewer accounts is the one thing
754
+ that makes reviewing an unchanged head worth another conversation: the
755
+ author answered a finding, so the earlier verdict no longer speaks to
756
+ the current state. Any other account (an author, a maintainer, a
757
+ different bot workflow posting a new result) counts, and the reviewer
758
+ account's own comments are excluded so its "reviewing now" and verdict
759
+ comments cannot re-trigger it.
760
+ """
761
+ if not submitted_at:
762
+ return False
763
+ for comment in self.gh_pages(f"/issues/{number}/comments"):
764
+ login = ((comment.get("user") or {}).get("login") or "").lower()
765
+ if login in {"", self.github_login.lower(), self.trigger_reviewer}:
766
+ continue
767
+ if (comment.get("created_at") or "") > submitted_at:
768
+ return True
769
+ return False
770
+
771
+ def _reviewed_current_head_without_clarification(self, number, sha):
772
+ """Whether the current head is already reviewed and nothing has changed.
773
+
774
+ A review on this exact head, followed by no clarifying comment from
775
+ anyone else, means another conversation would publish a second review of
776
+ the same code — the same verdict twice, spending a runtime to repeat it.
777
+ The head advancing, or any human comment arriving afterwards, makes the
778
+ head eligible again under the normal rules.
779
+
780
+ Returns the reviewed head's latest submission time (for the log line) or
781
+ None when the head should be reviewed.
782
+ """
783
+ if not sha:
784
+ return None
785
+ submitted_at = ""
786
+ for review in self.gh_pages(f"/pulls/{number}/reviews"):
787
+ if review.get("commit_id") != sha:
788
+ continue
789
+ if ((review.get("user") or {}).get("login") or "").lower() != (
790
+ self.github_login.lower()
791
+ ):
792
+ continue
793
+ # A dismissal removes the review's standing, exactly as
794
+ # maintainer_handoff._reviewer_states treats it: a maintainer who
795
+ # dismisses this account's verdict and re-requests a review on the
796
+ # same head is asking for a fresh look, not repeating a completed
797
+ # one. Keeping the dismissed review here would suppress that review
798
+ # permanently, because nothing else makes an unchanged head eligible.
799
+ if (review.get("state") or "").upper() == "DISMISSED":
800
+ continue
801
+ when = review.get("submitted_at") or ""
802
+ if when > submitted_at:
803
+ submitted_at = when
804
+ if not submitted_at:
805
+ return None
806
+ if self._clarified_since(number, submitted_at):
807
+ return None
808
+ return submitted_at
809
+
797
810
  def _unrequested_head(self, pr):
798
811
  """The current-head delivery key for an unrequested PR, or None.
799
812
 
@@ -804,56 +817,25 @@ class PullRequestReviewer(GitHubRepository):
804
817
  sha = pr["head"]["sha"]
805
818
  return f"scan:{self.repository}:{pr['number']}:{sha}"
806
819
 
807
- @property
808
- def _scan_cursor(self):
809
- """This repository's scan position, created lazily like the intake."""
810
- cursor = self.__dict__.get("_cursor")
811
- if cursor is None:
812
- cursor = self.__dict__["_cursor"] = ScanCursor(self.repository)
813
- return cursor
814
-
815
- def _explicit_candidate(self, pr, label):
816
- """Whether a PR was explicitly requested by a caller.
817
-
818
- An explicit `all-hands-bot` review request or a trigger label is a
819
- caller's decision, so it is never subject to the rotating window: every
820
- explicit candidate is examined on every scan, whatever the stored scan
821
- position is.
822
- """
823
- if label in {item["name"] for item in pr.get("labels", [])}:
824
- return True
825
- return self._outstanding_review_request(pr)
826
-
827
- def _rotating_window(self, prs, label):
828
- """The explicit candidates plus a bounded slice of the unrequested ones.
829
-
830
- Classifying an unrequested head costs a review read and the exact-head
831
- check/workflow reads, so examining every open PR in one scan is what let
832
- a single run exhaust the API budget and post a managed gate comment for
833
- every red or pending head. The unrequested backlog is therefore examined
834
- a bounded `SCAN_WINDOW` at a time, starting where the previous scan
835
- stopped (the per-repository position in the Automation KV store), so
836
- successive scans rotate through the whole backlog. Explicit candidates
837
- are always included, so a request is never delayed behind the window.
820
+ def _scan_candidates(self, prs, label):
821
+ """Return every potentially reviewable PR in the scheduled backlog.
822
+
823
+ The scan must classify the whole backlog so blocked, pending, draft, or
824
+ already-reviewed heads cannot hide eligible heads behind an inspection
825
+ window. The shared ReviewIntake applies the configured maximum only when
826
+ conversations are launched.
838
827
  """
839
- explicit, unrequested = [], []
828
+ candidates = []
840
829
  for pr in prs:
841
- if pr.get("draft") and label not in {
842
- item["name"] for item in pr.get("labels", [])
843
- } and not self._outstanding_review_request(pr):
844
- # A draft that is neither labeled nor requested is not reviewable
845
- # by the unrequested path either, so drop it before the full read.
830
+ labels = {item["name"] for item in pr.get("labels", [])}
831
+ if (
832
+ pr.get("draft")
833
+ and label not in labels
834
+ and not self._outstanding_review_request(pr)
835
+ ):
846
836
  continue
847
- if self._explicit_candidate(pr, label):
848
- explicit.append(pr)
849
- else:
850
- unrequested.append(pr)
851
- if not unrequested:
852
- return explicit
853
- start = self._scan_cursor.position(len(unrequested))
854
- window = unrequested[start : start + SCAN_WINDOW]
855
- self._scan_cursor.advance(start + len(window))
856
- return explicit + window
837
+ candidates.append(pr)
838
+ return candidates
857
839
 
858
840
  def run(self):
859
841
  repository_id = self.gh("GET", "")["id"]
@@ -861,7 +843,7 @@ class PullRequestReviewer(GitHubRepository):
861
843
  payload = self._event_payload()
862
844
  event_mode = payload is not None
863
845
  if not event_mode:
864
- prs = self._rotating_window(
846
+ prs = self._scan_candidates(
865
847
  self.gh_pages("/pulls?state=open&sort=updated&direction=asc"), label
866
848
  )
867
849
  else:
@@ -913,6 +895,46 @@ class PullRequestReviewer(GitHubRepository):
913
895
  unrequested_candidate = True
914
896
  if trigger is None and delivery_key is None:
915
897
  continue
898
+ # A head this account already reviewed, with no clarifying
899
+ # comment since, is done: another conversation would publish a
900
+ # second review of identical code. This must come before the
901
+ # delivery dispatch, because a fresh trigger (a new review
902
+ # request, or a label re-applied after the bot's own handoff)
903
+ # produces a new delivery key that would otherwise start a
904
+ # second review on an unchanged head.
905
+ already = self._reviewed_current_head_without_clarification(
906
+ pr["number"], pr["head"]["sha"]
907
+ )
908
+ if already:
909
+ print(
910
+ json.dumps(
911
+ {
912
+ "repository": self.repository,
913
+ "pr": pr["number"],
914
+ "head_sha": pr["head"]["sha"],
915
+ "disposition": "review-already-published",
916
+ "reviewed_at": already,
917
+ }
918
+ ),
919
+ flush=True,
920
+ )
921
+ if trigger is not None:
922
+ # Reconcile the completed review (clearing the label and
923
+ # running the maintainer handoff) where the review is
924
+ # this trigger's result. When it predates the trigger -
925
+ # the #698 ordering, a re-applied label - there is no
926
+ # completion to reconcile, so clear the label directly:
927
+ # otherwise the label stays and every later scan re-reads
928
+ # this head and re-logs with no effect.
929
+ if not self._finish_completed_review(
930
+ pr, trigger, trigger_label
931
+ ) and trigger_label:
932
+ self.gh(
933
+ "DELETE",
934
+ f"/issues/{pr['number']}/labels/"
935
+ f"{quote(trigger_label, safe='')}",
936
+ )
937
+ continue
916
938
  if delivery_key is None and self._finish_completed_review(
917
939
  pr, trigger, trigger_label
918
940
  ):
@@ -1008,6 +1030,7 @@ class PullRequestReviewer(GitHubRepository):
1008
1030
  subject=f"{repository_id}:pr:{pr['number']}",
1009
1031
  delivery=delivery_key or f"{trigger['id']}:{sha}",
1010
1032
  prompt=self._prompt(pr, trigger, trigger_label, delivery_key),
1033
+ head=sha,
1011
1034
  )
1012
1035
  print(
1013
1036
  json.dumps(
@@ -12,24 +12,62 @@ from urllib.parse import parse_qsl, urlencode, urlsplit
12
12
  from urllib.request import Request, urlopen
13
13
 
14
14
 
15
+ # The secret name of the GitHub token an OpenHands Cloud or Enterprise user's
16
+ # connected GitHub integration provides: how this run's sandbox serves it, and
17
+ # how a conversation sees it in its environment.
18
+ CLOUD_GITHUB_TOKEN_SECRET = "github_token"
19
+ CLOUD_GITHUB_TOKEN_ENV = "GITHUB_TOKEN"
20
+
21
+
22
+ def is_cloud_run() -> bool:
23
+ """Whether this run is on OpenHands Cloud or Enterprise.
24
+
25
+ The automation service hands a run the Agent Server URL only on a local
26
+ Agent Canvas; elsewhere the run talks to the OpenHands API instead.
27
+ """
28
+ return bool(os.environ.get("OPENHANDS_CLOUD_API_URL")) and not os.environ.get(
29
+ "AGENT_SERVER_URL"
30
+ )
31
+
32
+
33
+ def _load_cloud_secret(name: str) -> str | None:
34
+ """Read one named secret of this run's sandbox from the OpenHands API."""
35
+ api = os.environ["OPENHANDS_CLOUD_API_URL"].rstrip("/")
36
+ request = Request(
37
+ f"{api}/api/v1/sandboxes/{os.environ['SANDBOX_ID']}/settings/secrets/{name}",
38
+ headers={"X-Session-API-Key": os.environ["SESSION_API_KEY"]},
39
+ )
40
+ try:
41
+ with urlopen(request, timeout=90) as response:
42
+ return response.read().decode().strip()
43
+ except HTTPError as exc:
44
+ if exc.code == 404:
45
+ return None
46
+ raise
47
+
48
+
15
49
  def _load_secret(name: str) -> str:
16
- """Read one named secret from the environment or configured Agent Server."""
50
+ """Read one named secret from the environment, or from the configured Agent
51
+ Server on a local run and the OpenHands API on a cloud one."""
17
52
  value = os.environ.get(name)
18
53
  if value:
19
54
  return value
20
55
 
21
- from openhands.sdk.workspace import RemoteWorkspace
56
+ if is_cloud_run():
57
+ value = _load_cloud_secret(name)
58
+ else:
59
+ from openhands.sdk.workspace import RemoteWorkspace
22
60
 
23
- workspace = RemoteWorkspace(
24
- host=os.environ["AGENT_SERVER_URL"],
25
- api_key=os.environ["SESSION_API_KEY"],
26
- working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
27
- )
28
- try:
29
- secret = workspace.get_secrets([name]).get(name)
30
- value = secret.get_value() if secret else None
31
- finally:
32
- workspace.reset_client()
61
+ workspace = RemoteWorkspace(
62
+ host=os.environ["AGENT_SERVER_URL"],
63
+ api_key=os.environ["SESSION_API_KEY"],
64
+ working_dir=os.environ.get("WORKSPACE_BASE", "/workspace"),
65
+ )
66
+ try:
67
+ secret = workspace.get_secrets([name]).get(name)
68
+ value = secret.get_value() if secret else None
69
+ finally:
70
+ workspace.reset_client()
33
71
  if not value:
34
72
  raise ValueError(f"The GitHub credential {name} is unavailable")
35
73
  return value
@@ -95,11 +133,22 @@ class GitHubRepository:
95
133
  "Expected the environment variable containing the GitHub token"
96
134
  )
97
135
  self.token_name = github_token_secret
98
- self.token = _load_secret(github_token_secret)
136
+ try:
137
+ self.token = _load_secret(github_token_secret)
138
+ except ValueError:
139
+ if not is_cloud_run():
140
+ raise
141
+ # No secret is saved under that name, so use the user's connected
142
+ # GitHub integration, under the name a conversation sees it by.
143
+ self.token = _load_cloud_secret(CLOUD_GITHUB_TOKEN_SECRET)
144
+ if not self.token:
145
+ raise
146
+ self.token_name = CLOUD_GITHUB_TOKEN_ENV
99
147
  self.conversation = conversation
100
148
  self.conversation_id = str(conversation.id) if conversation else None
101
149
  self.dispatcher = dispatcher
102
- self.workspace = Path(os.environ["WORKSPACE_BASE"])
150
+ # A cloud run is not given a workspace base; its sandbox has /workspace.
151
+ self.workspace = Path(os.environ.get("WORKSPACE_BASE", "/workspace"))
103
152
  self.project = self.workspace
104
153
  self.evidence = self.workspace / "evidence"
105
154
  self.evidence.mkdir(exist_ok=True)
@@ -111,10 +160,13 @@ class GitHubRepository:
111
160
 
112
161
  @property
113
162
  def github_instructions(self):
163
+ # A cloud conversation runs in a sandbox of its own, so this run's
164
+ # workspace path means nothing to it.
165
+ where = "your working directory" if is_cloud_run() else self.project
114
166
  return (
115
167
  f"Use `GH_TOKEN=${self.token_name} gh api` for GitHub requests. "
116
168
  "Never print the credential value. "
117
- f"Only {self.repository} is in scope. Work in {self.project}. "
169
+ f"Only {self.repository} is in scope. Work in {where}. "
118
170
  "Do not modify the automation bundle or its configuration."
119
171
  )
120
172