mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
feat(gateway): broker GitHub publication (#126306)
* feat(gateway): broker GitHub publication * refactor(gateway): split publication owners * fix(gateway): enforce publication branch authority * fix(gateway): bind publication to remote identity * fix(gateway): bind publication recovery to remote state * fix(gateway): preserve publication git state * fix(gateway): retain publication recovery authority * fix(gateway): commit publication index atomically * fix(gateway): narrow publication index errors * refactor(gateway): keep publication CAS errors private * fix(gateway): recover publication index transactions * fix(agents): describe GitHub publication tool * fix(gateway): harden publication base fetch * fix(gateway): reject publication filter semantics * fix(gateway): verify publication creation base * refactor(agents): align publication tool options * fix(gateway): isolate publication object lineage * refactor(gateway): use shared table probe * test(gateway): keep publication helpers in routed suite * perf(ui): lazy-load GitHub publication request * fix(gateway): preserve publication support contracts * fix(gateway): recover publication before authority checks * fix(gateway): fence local publication snapshots * fix(android): format generated protocol models * fix(gateway): harden publication recovery * fix(gateway): fence publication recovery * fix(ui): reset completed publication cycles
This commit is contained in:
committed by
GitHub
parent
51599041bc
commit
0606e31d0e
@@ -1,8 +1,10 @@
|
||||
// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonClassDiscriminator
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
const val GATEWAY_PROTOCOL_VERSION = 4
|
||||
@@ -173,6 +175,44 @@ data class ProjectsListResult(
|
||||
val observedProjects: List<ProjectsListResultObservedProjectsItem>? = null,
|
||||
)
|
||||
|
||||
@SerialName("requested")
|
||||
@Serializable
|
||||
data class SessionGitHubPublicationRequested(
|
||||
val requestId: String,
|
||||
val message: String,
|
||||
) : SessionGitHubPublicationResult
|
||||
|
||||
@SerialName("publishing")
|
||||
@Serializable
|
||||
data class SessionGitHubPublicationPublishing(
|
||||
val requestId: String,
|
||||
val message: String,
|
||||
) : SessionGitHubPublicationResult
|
||||
|
||||
@SerialName("published")
|
||||
@Serializable
|
||||
data class SessionGitHubPublicationPublished(
|
||||
val requestId: String,
|
||||
val url: String,
|
||||
val repository: String,
|
||||
val branch: String,
|
||||
val headCommit: String,
|
||||
) : SessionGitHubPublicationResult
|
||||
|
||||
@SerialName("failed")
|
||||
@Serializable
|
||||
data class SessionGitHubPublicationFailed(
|
||||
val requestId: String,
|
||||
val code: String,
|
||||
val message: String,
|
||||
val nextAction: String,
|
||||
) : SessionGitHubPublicationResult
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
@JsonClassDiscriminator("status")
|
||||
sealed interface SessionGitHubPublicationResult
|
||||
|
||||
@Serializable
|
||||
data class GatewayEventFrameStateVersion(
|
||||
val presence: Long,
|
||||
@@ -591,6 +631,7 @@ enum class GatewayMethod(
|
||||
ProgressCardPut("progressCard.put"),
|
||||
ToolsGithubStatus("tools.github.status"),
|
||||
ToolsGithubConfigure("tools.github.configure"),
|
||||
SessionsGithubPublish("sessions.github.publish"),
|
||||
DiagnosticsLanes("diagnostics.lanes"),
|
||||
}
|
||||
|
||||
|
||||
+31
@@ -65,4 +65,35 @@ class GatewayProtocolGeneratedTest {
|
||||
assertEquals(events.size, events.toSet().size)
|
||||
assertEquals("sessions.move", GatewayMethod.SessionsMove.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun githubPublicationResultsRoundTripAsATypedUnion() {
|
||||
val cases =
|
||||
listOf(
|
||||
"""{"requestId":"request-1","status":"requested","message":"Accepted."}""" to
|
||||
SessionGitHubPublicationRequested::class,
|
||||
"""{"requestId":"request-1","status":"publishing","message":"Publishing."}""" to
|
||||
SessionGitHubPublicationPublishing::class,
|
||||
"""{"requestId":"request-1","status":"published","url":"https://github.com/openclaw/openclaw/pull/1","repository":"openclaw/openclaw","branch":"openclaw/task","headCommit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}""" to
|
||||
SessionGitHubPublicationPublished::class,
|
||||
"""{"requestId":"request-1","status":"failed","code":"push_rejected","message":"Failed.","nextAction":"Check access."}""" to
|
||||
SessionGitHubPublicationFailed::class,
|
||||
)
|
||||
|
||||
for ((payload, expectedType) in cases) {
|
||||
val decoded = json.decodeFromString(SessionGitHubPublicationResult.serializer(), payload)
|
||||
assertEquals(expectedType, decoded::class)
|
||||
val encoded =
|
||||
json.encodeToJsonElement(SessionGitHubPublicationResult.serializer(), decoded).jsonObject
|
||||
assertEquals(
|
||||
json
|
||||
.parseToJsonElement(payload)
|
||||
.jsonObject
|
||||
.getValue("status")
|
||||
.jsonPrimitive
|
||||
.content,
|
||||
encoded.getValue("status").jsonPrimitive.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +525,13 @@
|
||||
"model"
|
||||
]
|
||||
},
|
||||
"github_publish": {
|
||||
"emoji": "🔀",
|
||||
"title": "GitHub Publish",
|
||||
"detailKeys": [
|
||||
"title"
|
||||
]
|
||||
},
|
||||
"sessions": {
|
||||
"emoji": "🗂️",
|
||||
"title": "Session Settings",
|
||||
|
||||
@@ -6771,6 +6771,140 @@ public struct SessionsObserverVisibilityResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGitHubPublishParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let idempotencykey: String
|
||||
public let title: String?
|
||||
public let body: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String? = nil,
|
||||
idempotencykey: String,
|
||||
title: String? = nil,
|
||||
body: String? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.idempotencykey = idempotencykey
|
||||
self.title = title
|
||||
self.body = body
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case sessionkey = "sessionKey"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case title
|
||||
case body
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGitHubPublicationRequested: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let status: String
|
||||
public let message: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
status: String,
|
||||
message: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.status = status
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case status
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGitHubPublicationPublishing: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let status: String
|
||||
public let message: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
status: String,
|
||||
message: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.status = status
|
||||
self.message = message
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case status
|
||||
case message
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGitHubPublicationPublished: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let status: String
|
||||
public let url: String
|
||||
public let repository: String
|
||||
public let branch: String
|
||||
public let headcommit: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
status: String,
|
||||
url: String,
|
||||
repository: String,
|
||||
branch: String,
|
||||
headcommit: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.status = status
|
||||
self.url = url
|
||||
self.repository = repository
|
||||
self.branch = branch
|
||||
self.headcommit = headcommit
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case status
|
||||
case url
|
||||
case repository
|
||||
case branch
|
||||
case headcommit = "headCommit"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionGitHubPublicationFailed: Codable, Sendable {
|
||||
public let requestid: String
|
||||
public let status: String
|
||||
public let code: AnyCodable
|
||||
public let message: String
|
||||
public let nextaction: String
|
||||
|
||||
public init(
|
||||
requestid: String,
|
||||
status: String,
|
||||
code: AnyCodable,
|
||||
message: String,
|
||||
nextaction: String)
|
||||
{
|
||||
self.requestid = requestid
|
||||
self.status = status
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.nextaction = nextaction
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case requestid = "requestId"
|
||||
case status
|
||||
case code
|
||||
case message
|
||||
case nextaction = "nextAction"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionSharingIdentity: Codable, Sendable {
|
||||
public let type: AnyCodable
|
||||
public let id: String
|
||||
@@ -21186,6 +21320,43 @@ public enum SecretStoreEntry: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum SessionGitHubPublicationResult: Codable, Sendable {
|
||||
case requested(SessionGitHubPublicationRequested)
|
||||
case publishing(SessionGitHubPublicationPublishing)
|
||||
case published(SessionGitHubPublicationPublished)
|
||||
case failed(SessionGitHubPublicationFailed)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discriminator = "status"
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let discriminator = try container.decode(String.self, forKey: .discriminator)
|
||||
switch discriminator {
|
||||
case "requested": self = try .requested(SessionGitHubPublicationRequested(from: decoder))
|
||||
case "publishing": self = try .publishing(SessionGitHubPublicationPublishing(from: decoder))
|
||||
case "published": self = try .published(SessionGitHubPublicationPublished(from: decoder))
|
||||
case "failed": self = try .failed(SessionGitHubPublicationFailed(from: decoder))
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .discriminator,
|
||||
in: container,
|
||||
debugDescription: "Unknown SessionGitHubPublicationResult discriminator value"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
switch self {
|
||||
case .requested(let value): try value.encode(to: encoder)
|
||||
case .publishing(let value): try value.encode(to: encoder)
|
||||
case .published(let value): try value.encode(to: encoder)
|
||||
case .failed(let value): try value.encode(to: encoder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum SessionPlacement: Codable, Sendable {
|
||||
case local(LocalSessionPlacement)
|
||||
case requested(RequestedSessionPlacement)
|
||||
|
||||
+25
@@ -158,4 +158,29 @@ struct GatewayProtocolGeneratedModelsTests {
|
||||
#expect(encoded["scope"] as? String == expectedScope)
|
||||
#expect(encoded["mode"] as? String == (expectedManaged ? "managed" : "inherit"))
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(#"{"requestId":"request-1","status":"requested","message":"Accepted."}"#, "requested"),
|
||||
(#"{"requestId":"request-1","status":"publishing","message":"Publishing."}"#, "publishing"),
|
||||
(#"{"requestId":"request-1","status":"published","url":"https://github.com/openclaw/openclaw/pull/1","repository":"openclaw/openclaw","branch":"openclaw/task","headCommit":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#, "published"),
|
||||
(#"{"requestId":"request-1","status":"failed","code":"push_rejected","message":"Failed.","nextAction":"Check access."}"#, "failed"),
|
||||
])
|
||||
func `GitHub publication results round trip as a typed union`(
|
||||
json: String,
|
||||
expectedStatus: String) throws
|
||||
{
|
||||
let result = try JSONDecoder().decode(
|
||||
SessionGitHubPublicationResult.self,
|
||||
from: Data(json.utf8))
|
||||
switch result {
|
||||
case .requested: #expect(expectedStatus == "requested")
|
||||
case .publishing: #expect(expectedStatus == "publishing")
|
||||
case .published: #expect(expectedStatus == "published")
|
||||
case .failed: #expect(expectedStatus == "failed")
|
||||
}
|
||||
|
||||
let encoded = try #require(
|
||||
JSONSerialization.jsonObject(with: JSONEncoder().encode(result)) as? [String: Any])
|
||||
#expect(encoded["status"] as? String == expectedStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"startupJsGzipBytes": 344429,
|
||||
"reason": "Channel conversation avatars (PR #125668): lead-slot render branch + row plumbing on top of the 344395 B main baseline; avatar element itself is code-split out of startup",
|
||||
"updatedAt": "2026-08-18"
|
||||
"startupJsGzipBytes": 345578,
|
||||
"reason": "GitHub publication controls: lazy request chunk and branch-row state",
|
||||
"updatedAt": "2026-08-19"
|
||||
}
|
||||
|
||||
@@ -211,6 +211,8 @@ The bundled Crabbox provider advertises whatever machine classes the configured
|
||||
|
||||
Completed cloud turns reconcile eligible, size-bounded workspace files back into the session's managed worktree before the turn claim is released. Worker-turn uses its terminal worker event to create the durable pending-result fence. Remote-exec waits for workspace quiescence and enters the same reconciliation flow after the local Codex attempt. Before applying the result, the Gateway stages complete authenticated base/current manifests plus each changed resulting blob as a Git ref under `refs/openclaw/worker-results/`; deletions are represented by the manifests and need no blob. This keeps the cloud delta recoverable even if the Gateway stops during the apply without duplicating unchanged baseline content. Workspace results use Git file semantics: regular files, executable bits, symlinks, additions, changes, and deletions are retained, while empty directories and other directory modes are not. The resulting file changes remain in the managed worktree for normal review and commit.
|
||||
|
||||
To publish the finished work, the agent calls `github_publish` as its final action and then completes the turn. The call records only a single-line title, body, and idempotency key. After reconciliation is durably accepted, but before the exact turn claim is released, the Gateway re-resolves the session-owned managed worktree and effective GitHub identity. It uses the title as the commit subject, appends deterministic verified participant trailers, pushes the authoritative branch through an exact HTTPS path, and creates or reuses a draft pull request. The terminal transcript entry contains either the pull request URL or a typed failure with the next action. A restart resumes from the accepted workspace-result fence and remote branch or pull-request evidence; it never gives the recovered worker new forge authority.
|
||||
|
||||
Apply uses the dispatch-time manifest as the merge base. Cloud-only changes are applied, local-only changes stay in place, and paths changed on both sides use a three-way keep-local policy. A conflicted turn still finishes: the transcript reports the bounded path summary and staged result ref, the placement exposes the same conflict for the Control UI, and non-conflicting cloud changes remain applied. The notice includes `git show <ref>:<path>` to inspect a present cloud file and a top-level literal-pathspec `git checkout <ref> -- <path>` command to take it from any workspace directory. Run the commands in Bash or zsh (Git Bash on Windows). If inspect says the path does not exist, the cloud result deleted it; verify and remove the retained local path manually. If checkout reports a file/directory obstruction, move or remove the blocking local path and retry. If the staged ref itself is gone, treat the notice as stale and do not change the local path. Conflicted staged refs remain available after the normal turn fence is released; a later clean result clears the notice and retires the old ref, while explicit fence removal is the final cleanup boundary.
|
||||
|
||||
While a fenced result is still reconciling, a new turn waits up to 15 seconds for the prior claim to release. If it is still busy, the turn fails with an actionable “previous cloud turn's workspace result is still reconciling” message and can be retried shortly. On restart, recovery discovers pending and staged results before stale-claim cleanup, completes or retries their local apply, and reclaims dead environments only after preserving the result. The bounded SQLite rollback journal makes an interrupted filesystem apply recoverable without replaying already accepted mutations.
|
||||
@@ -264,6 +266,7 @@ The bundled Crabbox provider does not support Cloud Worker Desktop after node tr
|
||||
- **Minted credentials, hashed at rest.** Each dispatch mints a worker credential; the Gateway stores only its hash. Credential rotation and owner-epoch fencing guarantee at most one live owner per session — a stale worker that reconnects is fenced, never merged.
|
||||
- **Environment-bound enrollment.** One short-lived node-only setup credential is bound to the durable environment before allocation. Its first authenticated Ed25519 device identity is recorded atomically with setup completion; replay cannot substitute an unrelated node.
|
||||
- **No standing model, forge, or cloud credentials on the box.** OpenClaw worker turns proxy inference by `{provider, model}` reference. Codex remote-exec keeps the app-server plus ChatGPT subscription or API-key auth on the Gateway and sends only sandbox operations to the box. Remote-exec requires prepared auth and rejects ambient auth fallback. Workspace git commits are authored without forge credentials, and Crabbox AWS lease metadata is checked authoritatively for an instance role before setup. Keep setup commands credential-free too.
|
||||
- **Gateway-owned GitHub publication.** Publication credentials stay in the effective managed or native GitHub profile on the Gateway. The broker disables repository hooks, refuses configured Git clean filters, uses a temporary index and `git commit-tree`, pushes only a reconstructed public HTTPS URL with a command-local `gh auth git-credential` helper, and never writes a bearer token to argv, a remote URL, `.git/config`, a worker payload, or a transcript.
|
||||
- **Provider-owned egress.** Gateway-proxied inference removes any OpenClaw need for direct model access, but OpenClaw does not rewrite provider firewalls. Restrict outbound traffic in the worker provider when the task requires it.
|
||||
- **Durable, exactly-once worker transcripts.** In worker-turn mode, the worker commits transcript batches through a compare-and-swap protocol against the session's leaf; a stale base fail-stops the run instead of duplicating or rebasing paid output. Remote-exec writes through the Gateway's normal local harness path.
|
||||
|
||||
@@ -283,6 +286,7 @@ The bundled Crabbox provider does not support Cloud Worker Desktop after node tr
|
||||
- **Cloud workspace conflict notice** — the turn completed and kept the local version of each listed path. Use the staged-ref commands in the notice to inspect or take the cloud version; no retry is required for the non-conflicting changes, which are already applied.
|
||||
- **Cloud session disk-space warning** — delete unneeded files from the remote workspace or stop the cloud worker before large writes. The warning clears automatically after the next successful sample shows enough free space; a failed sample leaves the last successful warning visible and does not affect the session lifecycle.
|
||||
- **“The previous cloud turn's workspace result is still reconciling”** — the Gateway waited briefly for the prior result's durable fence and could not acquire the session claim. Wait for reconciliation to finish, then retry the turn; restarting the Gateway is safe because recovery preserves staged results before reclaiming a dead worker.
|
||||
- **GitHub publication failed** — open **Agents → Tools → GitHub Identity** and confirm the effective `@login` and credential health. Save a new managed PAT when the identity changed or the existing credential stopped working. For push rejection, inspect repository write access and branch drift; the broker never force-pushes. For pull request rejection, grant pull-request write access and call `github_publish` again with a new tool call.
|
||||
- **Lease housekeeping** — `crabbox list --provider <backend> --json` is a read-only inventory. `crabbox stop --provider <backend> --id <lease>` and `crabbox release --provider <backend> --id <lease>` are destructive and release a lease manually. OpenClaw keeps the lease alive while its session is placed, then stops heartbeating during teardown so genuinely idle leases expire on the profile's `idleTimeout`. Crabbox 0.43.0 and older do not expose the heartbeat command; OpenClaw warns once per environment and cannot prevent coordinator-idle reaping on those binaries.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -246,9 +246,11 @@ Managed identity applies to the `gh` CLI/API account and optional Git author/com
|
||||
|
||||
Managed profiles provide execution and coordination identity; they are not an OS-user security sandbox. A process with unrestricted host execution under the same OS account can access account-owned files, including managed `gh` profiles. Use an OpenClaw sandbox, a dedicated host, or a dedicated OS user when adversarial isolation is required.
|
||||
|
||||
The profile is not forwarded to node hosts, OpenClaw sandboxes, remote-exec placements, or cloud workers; cloud workers remain credential-free. Direct branch or pull request publication is not part of this identity foundation and belongs to PR3 in this stacked series. Until that separate Gateway broker lands, repository remotes continue to use their existing credentials and publication workflow.
|
||||
The profile is not forwarded to node hosts, OpenClaw sandboxes, remote-exec placements, or cloud workers; those environments remain credential-free. The `github_publish` tool instead records a bounded publication request. For cloud and remote-exec turns, the Gateway waits until the exact workspace result is reconciled and accepted, then commits remaining changes as the verified effective GitHub user, pushes the authoritative session branch through a one-shot HTTPS credential helper, and creates or reuses a draft pull request. The tool and worker payload contain no repository authority or credential.
|
||||
|
||||
Verification proves which account answered the GitHub API request. Status distinguishes missing or invalid managed credentials, unverified transport failures, and GitHub rate limiting without returning `gh` diagnostics. It does not claim that fine-grained write permissions or Git transport access were remotely verified. GitHub App and brokered publication support remain future work.
|
||||
Local session-owned worktrees can use the same **Publish PR** action in the Control UI. The Gateway derives the managed worktree, repository, branch, base, and head from current session ownership. It never accepts those authority facts from the browser or model. Publication retries use a durable request ID, an exact commit marker, remote branch observation, and pull-request lookup by head branch so a Gateway restart or lost response does not create duplicate commits, pushes, or pull requests.
|
||||
|
||||
Verification proves which account answered the GitHub API request. Status distinguishes missing or invalid managed credentials, unverified transport failures, and GitHub rate limiting without returning `gh` diagnostics. It does not claim that fine-grained repository grants were remotely verified. This brokered-publication flow does not add browser or device authorization; the Settings setup remains PAT-based.
|
||||
|
||||
Control UI repository previews and project discovery use the separate optional `gateway.controlUi.github.token` service credential. They never consume an agent tool identity. When this SecretRef is explicit, OpenClaw excludes its exact environment or store name from agent execution. A custom name does not clear unrelated `GH_TOKEN` or `GITHUB_TOKEN` values used by native identity; a ref named `GH_TOKEN` or `GITHUB_TOKEN` excludes that exact variable.
|
||||
|
||||
|
||||
@@ -508,7 +508,7 @@ Capability toggles stay disabled until the Gateway, session, and runtime config
|
||||
- The session header shows a small facepile beside the workspace chip when other people are viewing the same session; it lists up to four viewer avatars with an overflow count and disappears when you are alone. On multi-user gateways the header also carries the permanent session owner chip and a facepile of up to four participants who have prompted the session (owner excluded); sidebar rows compress the same information into a pair-stack — owner in front, one peeking participant or a +N count behind (see [Multi-user mode](/concepts/multi-user#reading-the-avatars)).
|
||||
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
|
||||
- User-message bubbles carry transcript actions: a hover rewind button (confirm popover with a "Don't ask again" option) plus right-click **Rewind to here** and **Fork from here**. Rewind repoints the session to the state just before that message and returns its text to the composer for edit and resend (`sessions.rewind`, `operator.admin`); fork creates a new session from the active-path prefix before the message, opens it, and seeds its composer with the same text (`sessions.fork`, `operator.write`). Both actions disable with an explanatory tooltip while the agent is working, apply only to persisted user messages, and are rejected for sessions whose conversation is owned by an external agent harness. Rewind moves chat context only — files and other tool side effects are not reverted — and the pre-rewind transcript remains preserved in the append-only session store. When that store contains multiple transcript branches, the chat title bar shows a branch menu with each branch's latest message, message count, and recency; selecting an inactive branch switches the current session back to that preserved path (`sessions.branches.list`, `operator.read`; `sessions.branches.switch`, `operator.admin`). Branch switching is also unavailable while the agent is working, and selecting the already-active branch is a typed no-op error at the RPC boundary.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. The Gateway polls only sessions visible in a connected Control UI and pushes changed snapshots through `controlUi.sessionPullRequests.changed`; it uses the explicit Control UI GitHub credential or the shared process-environment fallback. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). Once the pushed branch has commits to compare, the row adds a Create PR button that opens GitHub's new-pull-request page; before that, a session with changed files (committed, uncommitted, or untracked) still gets the row without the button. The row hides itself while an open or draft PR exists; once the branch's PR is merged and the pushed tip still matches the merged head, the row disappears too (returning without the Create PR button only when new local work appears, and with it once new commits are pushed past the merged head). The branch row comes from local git only, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
- When a session's checkout sits on a non-default branch of a GitHub repository, the chat view pins pull request chips above the composer: PR number, repo, branch, diff counts, a CI pill, and draft/merged/closed state, each linking to the PR. The row shows at most two chips — live (open/draft) PRs first — and a "Show more" button reveals collapsed merged/closed history. The CI pill opens a small CI monitoring popover with passed/failed/running/skipped check counts and a link to the PR's checks page. The Gateway polls only sessions visible in a connected Control UI and pushes changed snapshots through `controlUi.sessionPullRequests.changed`; it uses the explicit Control UI GitHub credential or the shared process-environment fallback for this read-only preview. When the GitHub API rate limit is hit, chips keep the last known status and show a warning that the status may be out of date; dismissing a chip hides it for that session in the current browser profile. Before any PR exists, the row shows the branch itself — repo, branch name, and the +/− size of the diff against the default-branch merge base (committed and uncommitted work). **Publish PR** sends only the session key and bounded presentation text to the Gateway-owned publication broker. The broker derives the repository and branch from session ownership, uses the effective agent GitHub identity rather than the preview credential, and returns the draft pull request URL or an actionable typed failure. The row hides itself while an open or draft PR exists; once the branch's PR is merged and the pushed tip still matches the merged head, the row disappears too. The branch row comes from local git, so it stays available while GitHub is rate limited and carries the same stale-status warning, since "no PR found" cannot be trusted until the limit resets.
|
||||
- The session diff panel shows what a session's checkout actually changed: the branch button in the workspace rail or chat title bar opens a dense per-file viewer with normalized added/deleted/modified counts, collapsible files, wrapping and unified/split layouts, file copy/open/editor actions, and "N unmodified lines" markers between hunks. The footer switches between all changes, uncommitted work, and individual commits while showing how far the branch is ahead of its merge base; committed branches also provide a copyable local sync command. Diffs are computed server-side through the `sessions.diff` Gateway method (`operator.read` scope); binary and oversized files degrade to stats-only entries, and the button only appears when the connected Gateway advertises `sessions.diff`.
|
||||
- Every Chat pane has a title bar. Click the session title to rename it; the workspace chip copies the checkout path or branch and can reveal local Gateway workspaces in the host file manager. Remote and exec-node sessions keep copy actions but hide reveal.
|
||||
- The **Files** tab in each Chat pane's unified side panel lists thread files, project files, and artifacts. Reopen it with ⇧⌘B, the files toggle in the title bar, or the panel's **+** menu; the title-bar toggle carries a changed-file count badge.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
SessionGitHubPublicationResultSchema,
|
||||
SessionGitHubPublishParamsSchema,
|
||||
} from "./schema/session-github-publication.js";
|
||||
export {
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
WorkerGitHubPublishParamsSchema,
|
||||
WorkerGitHubPublishResponseFrameSchema,
|
||||
} from "./schema/worker-admission.js";
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./error-details.js";
|
||||
export * from "./github-publication-api.js";
|
||||
export * from "./session-agent-status.js";
|
||||
export * from "./terminal-validators.js";
|
||||
export {
|
||||
|
||||
@@ -36,6 +36,7 @@ export * from "./schema/secrets.js";
|
||||
export * from "./schema/session-placement.js";
|
||||
export * from "./schema/session-discussion.js";
|
||||
export * from "./schema/sessions.js";
|
||||
export * from "./schema/session-github-publication.js";
|
||||
export * from "./schema/sessions-viewer-presence.js";
|
||||
export * from "./schema/sessions-sharing.js";
|
||||
export * from "./schema/sessions-suggestions.js";
|
||||
|
||||
+10
@@ -1,9 +1,19 @@
|
||||
import * as sessionDiscussion from "./session-discussion.js";
|
||||
import * as sessionGitHubPublication from "./session-github-publication.js";
|
||||
import * as sessionPlacement from "./session-placement.js";
|
||||
import * as sessionsSharing from "./sessions-sharing.js";
|
||||
import * as sessionsSuggestions from "./sessions-suggestions.js";
|
||||
|
||||
export const SessionCollaborationProtocolSchemas = {
|
||||
SessionGitHubPublishParams: sessionGitHubPublication.SessionGitHubPublishParamsSchema,
|
||||
SessionGitHubPublicationRequested:
|
||||
sessionGitHubPublication.SessionGitHubPublicationRequestedSchema,
|
||||
SessionGitHubPublicationPublishing:
|
||||
sessionGitHubPublication.SessionGitHubPublicationPublishingSchema,
|
||||
SessionGitHubPublicationPublished:
|
||||
sessionGitHubPublication.SessionGitHubPublicationPublishedSchema,
|
||||
SessionGitHubPublicationFailed: sessionGitHubPublication.SessionGitHubPublicationFailedSchema,
|
||||
SessionGitHubPublicationResult: sessionGitHubPublication.SessionGitHubPublicationResultSchema,
|
||||
SessionVisibility: sessionsSharing.SessionVisibilitySchema,
|
||||
SessionSharingIdentity: sessionsSharing.SessionSharingIdentitySchema,
|
||||
SessionSharingRole: sessionsSharing.SessionSharingRoleSchema,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Value } from "typebox/value";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SessionGitHubPublicationResultSchema,
|
||||
SessionGitHubPublishParamsSchema,
|
||||
} from "./session-github-publication.js";
|
||||
|
||||
describe("session GitHub publication protocol", () => {
|
||||
it("accepts bounded intent without caller-owned repository authority", () => {
|
||||
expect(
|
||||
Value.Check(SessionGitHubPublishParamsSchema, {
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Fix the gateway",
|
||||
body: "Explains the change.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Value.Check(SessionGitHubPublishParamsSchema, {
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Fix the gateway\nCo-authored-by: unverified <unverified@example.test>",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(SessionGitHubPublishParamsSchema, {
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Fix the gateway",
|
||||
commitMessage: "model-controlled trailer",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["token", "secret"],
|
||||
["repository", "openclaw/openclaw"],
|
||||
["branch", "main"],
|
||||
])("rejects caller-owned %s authority independently", (field, value) => {
|
||||
expect(
|
||||
Value.Check(SessionGitHubPublishParamsSchema, {
|
||||
idempotencyKey: "tool-call-1",
|
||||
[field]: value,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
requestId: "request-1",
|
||||
status: "requested",
|
||||
message: "Publication was accepted.",
|
||||
},
|
||||
{
|
||||
requestId: "request-1",
|
||||
status: "publishing",
|
||||
message: "The Gateway is publishing.",
|
||||
},
|
||||
{
|
||||
requestId: "request-1",
|
||||
status: "published",
|
||||
url: "https://github.com/openclaw/openclaw/pull/1",
|
||||
repository: "openclaw/openclaw",
|
||||
branch: "openclaw/task",
|
||||
headCommit: "a".repeat(40),
|
||||
},
|
||||
{
|
||||
requestId: "request-1",
|
||||
status: "failed",
|
||||
code: "push_rejected",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: "Check branch access and retry.",
|
||||
},
|
||||
])("accepts the closed $status result", (result) => {
|
||||
expect(Value.Check(SessionGitHubPublicationResultSchema, result)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extra fields from terminal results", () => {
|
||||
expect(
|
||||
Value.Check(SessionGitHubPublicationResultSchema, {
|
||||
requestId: "request-1",
|
||||
status: "failed",
|
||||
code: "push_rejected",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: "Check branch access and retry.",
|
||||
token: "secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Type, type Static } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
export const GitHubPublicationTitleSchema = Type.String({
|
||||
minLength: 1,
|
||||
maxLength: 256,
|
||||
pattern: "^[^\\r\\n]*\\S[^\\r\\n]*$",
|
||||
});
|
||||
export const GitHubPublicationBodySchema = Type.String({ minLength: 1, maxLength: 8 * 1024 });
|
||||
|
||||
export const SessionGitHubPublishParamsSchema = closedObject({
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
idempotencyKey: NonEmptyString,
|
||||
title: Type.Optional(GitHubPublicationTitleSchema),
|
||||
body: Type.Optional(GitHubPublicationBodySchema),
|
||||
});
|
||||
|
||||
const SessionGitHubPublicationBaseSchema = {
|
||||
requestId: NonEmptyString,
|
||||
};
|
||||
|
||||
export const SessionGitHubPublicationRequestedSchema = closedObject({
|
||||
...SessionGitHubPublicationBaseSchema,
|
||||
status: Type.Literal("requested"),
|
||||
message: NonEmptyString,
|
||||
});
|
||||
export const SessionGitHubPublicationPublishingSchema = closedObject({
|
||||
...SessionGitHubPublicationBaseSchema,
|
||||
status: Type.Literal("publishing"),
|
||||
message: NonEmptyString,
|
||||
});
|
||||
export const SessionGitHubPublicationPublishedSchema = closedObject({
|
||||
...SessionGitHubPublicationBaseSchema,
|
||||
status: Type.Literal("published"),
|
||||
url: NonEmptyString,
|
||||
repository: NonEmptyString,
|
||||
branch: NonEmptyString,
|
||||
headCommit: NonEmptyString,
|
||||
});
|
||||
export const SessionGitHubPublicationFailedSchema = closedObject({
|
||||
...SessionGitHubPublicationBaseSchema,
|
||||
status: Type.Literal("failed"),
|
||||
code: Type.Union([
|
||||
Type.Literal("identity_changed"),
|
||||
Type.Literal("identity_unavailable"),
|
||||
Type.Literal("session_changed"),
|
||||
Type.Literal("workspace_changed"),
|
||||
Type.Literal("not_git"),
|
||||
Type.Literal("not_github"),
|
||||
Type.Literal("no_changes"),
|
||||
Type.Literal("push_rejected"),
|
||||
Type.Literal("github_rejected"),
|
||||
Type.Literal("unavailable"),
|
||||
]),
|
||||
message: NonEmptyString,
|
||||
nextAction: NonEmptyString,
|
||||
});
|
||||
|
||||
export const SessionGitHubPublicationResultSchema = Type.Union([
|
||||
SessionGitHubPublicationRequestedSchema,
|
||||
SessionGitHubPublicationPublishingSchema,
|
||||
SessionGitHubPublicationPublishedSchema,
|
||||
SessionGitHubPublicationFailedSchema,
|
||||
]);
|
||||
|
||||
export type SessionGitHubPublishParams = Static<typeof SessionGitHubPublishParamsSchema>;
|
||||
export type SessionGitHubPublicationRequested = Static<
|
||||
typeof SessionGitHubPublicationRequestedSchema
|
||||
>;
|
||||
export type SessionGitHubPublicationPublishing = Static<
|
||||
typeof SessionGitHubPublicationPublishingSchema
|
||||
>;
|
||||
export type SessionGitHubPublicationPublished = Static<
|
||||
typeof SessionGitHubPublicationPublishedSchema
|
||||
>;
|
||||
export type SessionGitHubPublicationFailed = Static<typeof SessionGitHubPublicationFailedSchema>;
|
||||
export type SessionGitHubPublicationResult = Static<typeof SessionGitHubPublicationResultSchema>;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
WorkerAdmissionResponseFrameSchema,
|
||||
WorkerHeartbeatRequestFrameSchema,
|
||||
WorkerHeartbeatResponseFrameSchema,
|
||||
WorkerGitHubPublishResponseFrameSchema,
|
||||
WorkerLiveEventRequestFrameSchema,
|
||||
WorkerLiveEventResponseFrameSchema,
|
||||
WorkerProtocolCloseReasonSchema,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
WorkerTranscriptCommitResponseFrameSchema,
|
||||
WORKER_PROVIDER_REPLAY_MAX_DATA_BYTES,
|
||||
WORKER_LAUNCH_V2_PROTOCOL_FEATURE,
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
WORKER_PROTOCOL_FEATURES,
|
||||
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
|
||||
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
validateWorkerAdmissionHandshake,
|
||||
validateWorkerConnectRequestFrame,
|
||||
validateWorkerHeartbeatParams,
|
||||
validateWorkerGitHubPublishParams,
|
||||
validateWorkerLiveEventParams,
|
||||
validateWorkerSessionsSendParams,
|
||||
validateWorkerSessionsSpawnParams,
|
||||
@@ -280,10 +283,19 @@ describe("worker protocol schemas", () => {
|
||||
sessionKey: "agent:main:dashboard:child",
|
||||
message: "report status",
|
||||
};
|
||||
const publish = { toolCallId: "call-publish", title: "Publish the fix" };
|
||||
expect(validateWorkerSessionsSpawnParams(spawn)).toBe(true);
|
||||
expect(validateWorkerSessionsSendParams(send)).toBe(true);
|
||||
expect(validateWorkerGitHubPublishParams(publish)).toBe(true);
|
||||
expect(validateWorkerSessionsSpawnParams({ ...spawn, unexpected: true })).toBe(false);
|
||||
expect(validateWorkerSessionsSendParams({ ...send, message: "" })).toBe(false);
|
||||
expect(validateWorkerGitHubPublishParams({ ...publish, token: "secret" })).toBe(false);
|
||||
expect(validateWorkerGitHubPublishParams({ ...publish, repository: "openclaw/openclaw" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(validateWorkerGitHubPublishParams({ ...publish, branch: "main" })).toBe(false);
|
||||
expect(validateWorkerGitHubPublishParams({ ...publish, title: "Fix\rInject" })).toBe(false);
|
||||
expect(validateWorkerGitHubPublishParams({ ...publish, commitMessage: "Inject" })).toBe(false);
|
||||
const escaped = "\0";
|
||||
const requestBytes = (method: string, requestParams: object) =>
|
||||
Buffer.byteLength(
|
||||
@@ -335,6 +347,16 @@ describe("worker protocol schemas", () => {
|
||||
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
|
||||
);
|
||||
expect(validateWorkerSessionsSendParams(impossibleSend)).toBe(false);
|
||||
|
||||
const maximalPublish = {
|
||||
toolCallId: escaped.repeat(256),
|
||||
title: escaped.repeat(256),
|
||||
body: escaped.repeat(8 * 1024),
|
||||
};
|
||||
expect(validateWorkerGitHubPublishParams(maximalPublish)).toBe(true);
|
||||
expect(requestBytes("worker.github.publish", maximalPublish)).toBeLessThanOrEqual(
|
||||
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
|
||||
);
|
||||
expect(
|
||||
validateWorkerSessionsSpawnParams({
|
||||
...spawn,
|
||||
@@ -342,6 +364,7 @@ describe("worker protocol schemas", () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(WORKER_PROTOCOL_FEATURES).toContain(WORKER_SESSION_TOOLS_PROTOCOL_FEATURE);
|
||||
expect(WORKER_PROTOCOL_FEATURES).toContain(WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE);
|
||||
|
||||
const response = {
|
||||
type: "res" as const,
|
||||
@@ -351,6 +374,7 @@ describe("worker protocol schemas", () => {
|
||||
};
|
||||
expect(Value.Check(WorkerSessionsSpawnResponseFrameSchema, response)).toBe(true);
|
||||
expect(Value.Check(WorkerSessionsSendResponseFrameSchema, response)).toBe(true);
|
||||
expect(Value.Check(WorkerGitHubPublishResponseFrameSchema, response)).toBe(true);
|
||||
expect(
|
||||
Value.Check(WorkerSessionsSendResponseFrameSchema, {
|
||||
...response,
|
||||
|
||||
@@ -2,6 +2,10 @@ import { Type, type Static, type TProperties } from "typebox";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../client-info.js";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { FailoverReasonSchema } from "./failover-reason.js";
|
||||
import {
|
||||
GitHubPublicationBodySchema,
|
||||
GitHubPublicationTitleSchema,
|
||||
} from "./session-github-publication.js";
|
||||
import { withSince } from "./since.js";
|
||||
import {
|
||||
LiveIntegerSchema,
|
||||
@@ -37,12 +41,14 @@ export const WORKER_PROTOCOL_METHODS = [
|
||||
"worker.live-event",
|
||||
"worker.sessions.spawn",
|
||||
"worker.sessions.send",
|
||||
"worker.github.publish",
|
||||
] as const;
|
||||
export const WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE = "worker-transcript-commit-v1";
|
||||
export const WORKER_LIVE_EVENT_PROTOCOL_FEATURE = "worker-live-event-v1";
|
||||
export const WORKER_LAUNCH_V2_PROTOCOL_FEATURE = "worker-launch-v2";
|
||||
export const WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE = "worker-execution-context-v2";
|
||||
export const WORKER_SESSION_TOOLS_PROTOCOL_FEATURE = "worker-session-tools-v1";
|
||||
export const WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE = "worker-github-publication-v1";
|
||||
export const WORKER_PROTOCOL_FEATURES = [
|
||||
"worker-heartbeat-v1",
|
||||
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
|
||||
@@ -51,6 +57,7 @@ export const WORKER_PROTOCOL_FEATURES = [
|
||||
// launch V2: an older gateway would adopt this worker and send the old shape.
|
||||
WORKER_EXECUTION_CONTEXT_PROTOCOL_FEATURE,
|
||||
WORKER_SESSION_TOOLS_PROTOCOL_FEATURE,
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
"worker-inference-v1",
|
||||
] as const;
|
||||
export const WORKER_PROTOCOL_MAX_METHOD_LENGTH = 64;
|
||||
@@ -217,6 +224,12 @@ export const WorkerSessionsSendParamsSchema = closedObject({
|
||||
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 0, maximum: 86_400 })),
|
||||
});
|
||||
|
||||
export const WorkerGitHubPublishParamsSchema = closedObject({
|
||||
toolCallId: WorkerSessionToolCallIdSchema,
|
||||
title: Type.Optional(GitHubPublicationTitleSchema),
|
||||
body: Type.Optional(GitHubPublicationBodySchema),
|
||||
});
|
||||
|
||||
export const WorkerSessionToolResultSchema = closedObject({
|
||||
resultJson: Type.String({ minLength: 2, maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
|
||||
});
|
||||
@@ -241,6 +254,16 @@ export const WorkerSessionsSendResponseFrameSchema = Type.Union([
|
||||
WorkerErrorResponseFrameSchema,
|
||||
]);
|
||||
|
||||
export const WorkerGitHubPublishResponseFrameSchema = Type.Union([
|
||||
closedObject({
|
||||
type: Type.Literal("res"),
|
||||
id: WorkerFrameIdSchema,
|
||||
ok: Type.Literal(true),
|
||||
payload: WorkerSessionToolResultSchema,
|
||||
}),
|
||||
WorkerErrorResponseFrameSchema,
|
||||
]);
|
||||
|
||||
const WorkerTranscriptTextContentSchema = closedObject({
|
||||
type: Type.Literal("text"),
|
||||
text: Type.String({ maxLength: WORKER_PROTOCOL_MAX_PAYLOAD_BYTES }),
|
||||
@@ -704,11 +727,15 @@ export type WorkerHeartbeatRequestFrame = Static<typeof WorkerHeartbeatRequestFr
|
||||
export type WorkerHeartbeatResponseFrame = Static<typeof WorkerHeartbeatResponseFrameSchema>;
|
||||
export type WorkerSessionsSpawnParams = Static<typeof WorkerSessionsSpawnParamsSchema>;
|
||||
export type WorkerSessionsSendParams = Static<typeof WorkerSessionsSendParamsSchema>;
|
||||
export type WorkerGitHubPublishParams = Static<typeof WorkerGitHubPublishParamsSchema>;
|
||||
export type WorkerSessionToolResult = Static<typeof WorkerSessionToolResultSchema>;
|
||||
export type WorkerSessionsSpawnResponseFrame = Static<
|
||||
typeof WorkerSessionsSpawnResponseFrameSchema
|
||||
>;
|
||||
export type WorkerSessionsSendResponseFrame = Static<typeof WorkerSessionsSendResponseFrameSchema>;
|
||||
export type WorkerGitHubPublishResponseFrame = Static<
|
||||
typeof WorkerGitHubPublishResponseFrameSchema
|
||||
>;
|
||||
export type WorkerTranscriptMessage = Static<typeof WorkerTranscriptMessageSchema>;
|
||||
export type WorkerProviderReplayState = Static<typeof WorkerProviderReplayStateSchema>;
|
||||
export type WorkerTranscriptCommitParams = Static<typeof WorkerTranscriptCommitParamsSchema>;
|
||||
|
||||
@@ -352,6 +352,8 @@ export const validateHooksStatusParams = compile(S.HooksStatusParamsSchema);
|
||||
export const validateToolsCatalogParams = compile(S.ToolsCatalogParamsSchema);
|
||||
export const validateToolsGitHubStatusParams = compile(S.ToolsGitHubStatusParamsSchema);
|
||||
export const validateToolsGitHubConfigureParams = compile(S.ToolsGitHubConfigureParamsSchema);
|
||||
export const validateSessionGitHubPublishParams = compile(S.SessionGitHubPublishParamsSchema);
|
||||
export const validateWorkerGitHubPublishParams = compile(S.WorkerGitHubPublishParamsSchema);
|
||||
export const validateToolsEffectiveParams = compile(S.ToolsEffectiveParamsSchema);
|
||||
export const validateToolsInvokeParams = compile(S.ToolsInvokeParamsSchema);
|
||||
export const validateSkillsBinsParams = compile(S.SkillsBinsParamsSchema);
|
||||
|
||||
@@ -113,8 +113,8 @@ const ownerModules = [
|
||||
...schemaModulesSource.matchAll(/^export \* from "\.\/schema\/([^"]+)\.js";$/gmu),
|
||||
].map(([, moduleName = ""]) => moduleName);
|
||||
check(
|
||||
ownerModules.length === 58 && new Set(ownerModules).size === ownerModules.length,
|
||||
"schema-modules.ts must contain one unique 58-module owner list",
|
||||
ownerModules.length === 59 && new Set(ownerModules).size === ownerModules.length,
|
||||
"schema-modules.ts must contain one unique 59-module owner list",
|
||||
);
|
||||
check(
|
||||
schemaModulesSource.split("\n").filter(Boolean).length === ownerModules.length,
|
||||
|
||||
@@ -61,6 +61,11 @@ const schemaNames = new Map<string, string>([
|
||||
["WorkerDesktopLaunchParams", "WorkerDesktopLaunchParams"],
|
||||
["WorkerDesktopLaunchResult", "WorkerDesktopLaunchResult"],
|
||||
["ProjectsListResult", "ProjectsListResult"],
|
||||
["SessionGitHubPublicationRequested", "SessionGitHubPublicationRequested"],
|
||||
["SessionGitHubPublicationPublishing", "SessionGitHubPublicationPublishing"],
|
||||
["SessionGitHubPublicationPublished", "SessionGitHubPublicationPublished"],
|
||||
["SessionGitHubPublicationFailed", "SessionGitHubPublicationFailed"],
|
||||
["SessionGitHubPublicationResult", "SessionGitHubPublicationResult"],
|
||||
]);
|
||||
|
||||
const androidEnums: EnumSpec[] = [
|
||||
@@ -227,6 +232,40 @@ function emitWireModels(): string[] {
|
||||
}
|
||||
|
||||
const nestedModels = new Map<string, JsonSchema>();
|
||||
const unionVariants = new Map<
|
||||
string,
|
||||
{ discriminator: string; literal: string; unionName: string }
|
||||
>();
|
||||
const discriminatedUnions = new Map<
|
||||
string,
|
||||
{ discriminator: string; variants: Array<{ literal: string }> }
|
||||
>();
|
||||
for (const [schemaName, kotlinName] of schemaNames) {
|
||||
const schema = protocolSchemas[schemaName];
|
||||
const branches = schema?.oneOf ?? schema?.anyOf;
|
||||
if (!branches || branches.length < 2 || branches.some((branch) => branch.type !== "object")) {
|
||||
continue;
|
||||
}
|
||||
const discriminator = Object.keys(branches[0]?.properties ?? {}).find((property) =>
|
||||
branches.every(
|
||||
(branch) => typeof literalValue(branch.properties?.[property] ?? {}) === "string",
|
||||
),
|
||||
);
|
||||
if (!discriminator) {
|
||||
continue;
|
||||
}
|
||||
const variants = branches.map((branch) => ({
|
||||
literal: literalValue(branch.properties?.[discriminator] ?? {}) as string,
|
||||
}));
|
||||
discriminatedUnions.set(kotlinName, { discriminator, variants });
|
||||
for (const [index, branch] of branches.entries()) {
|
||||
unionVariants.set(schemaSignature(branch), {
|
||||
discriminator,
|
||||
literal: variants[index]!.literal,
|
||||
unionName: kotlinName,
|
||||
});
|
||||
}
|
||||
}
|
||||
const kotlinType = (schema: JsonSchema, nestedName: string): string => {
|
||||
const selected = selectedSchemas.get(schema) ?? selectedSignatures.get(schemaSignature(schema));
|
||||
if (selected) {
|
||||
@@ -267,28 +306,52 @@ function emitWireModels(): string[] {
|
||||
throw new Error(`${name} must remain an object schema for Kotlin generation`);
|
||||
}
|
||||
const required = new Set(schema.required ?? []);
|
||||
const properties = Object.entries(schema.properties).map(([wireName, propertySchema]) => {
|
||||
const propertyName = lowerCamel(wireName);
|
||||
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
|
||||
const literal = literalValue(propertySchema);
|
||||
const optional = !required.has(wireName);
|
||||
return {
|
||||
annotation: propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
|
||||
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
|
||||
literal !== undefined ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
|
||||
},`,
|
||||
};
|
||||
});
|
||||
const variant = unionVariants.get(schemaSignature(schema));
|
||||
const properties = Object.entries(schema.properties)
|
||||
.filter(([wireName]) => wireName !== variant?.discriminator)
|
||||
.map(([wireName, propertySchema]) => {
|
||||
const propertyName = lowerCamel(wireName);
|
||||
const type = kotlinType(propertySchema, `${name}${upperCamel(wireName)}`);
|
||||
const literal = literalValue(propertySchema);
|
||||
const optional = !required.has(wireName);
|
||||
return {
|
||||
annotation:
|
||||
propertyName === wireName ? [] : [` @SerialName(${JSON.stringify(wireName)})`],
|
||||
declaration: ` val ${propertyName}: ${type}${optional ? "?" : ""}${
|
||||
literal !== undefined ? ` = ${kotlinLiteral(literal)}` : optional ? " = null" : ""
|
||||
},`,
|
||||
};
|
||||
});
|
||||
const fields: string[] = [];
|
||||
for (const property of properties) {
|
||||
fields.push(...property.annotation, property.declaration);
|
||||
}
|
||||
return ["@Serializable", `data class ${name}(`, ...fields, ")"].join("\n");
|
||||
return [
|
||||
...(variant ? [`@SerialName(${JSON.stringify(variant.literal)})`] : []),
|
||||
"@Serializable",
|
||||
`data class ${name}(`,
|
||||
...fields,
|
||||
`)${variant ? ` : ${variant.unionName}` : ""}`,
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const emitUnion = (
|
||||
name: string,
|
||||
union: { discriminator: string; variants: Array<{ literal: string }> },
|
||||
): string =>
|
||||
[
|
||||
"@OptIn(ExperimentalSerializationApi::class)",
|
||||
"@Serializable",
|
||||
`@JsonClassDiscriminator(${JSON.stringify(union.discriminator)})`,
|
||||
`sealed interface ${name}`,
|
||||
].join("\n");
|
||||
|
||||
const output: string[] = [];
|
||||
for (const [schemaName, kotlinName] of schemaNames) {
|
||||
output.push(emitModel(kotlinName, protocolSchemas[schemaName]!));
|
||||
const union = discriminatedUnions.get(kotlinName);
|
||||
output.push(
|
||||
union ? emitUnion(kotlinName, union) : emitModel(kotlinName, protocolSchemas[schemaName]!),
|
||||
);
|
||||
}
|
||||
for (const [nestedName, schema] of nestedModels) {
|
||||
if (!output.some((model) => model.startsWith(`@Serializable\ndata class ${nestedName}(`))) {
|
||||
@@ -332,8 +395,10 @@ async function generate(): Promise<void> {
|
||||
"// Generated by scripts/protocol-gen-kotlin.ts — do not edit by hand.",
|
||||
"package ai.openclaw.app.gateway",
|
||||
"",
|
||||
"import kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"import kotlinx.serialization.SerialName",
|
||||
"import kotlinx.serialization.Serializable",
|
||||
"import kotlinx.serialization.json.JsonClassDiscriminator",
|
||||
"import kotlinx.serialization.json.JsonElement",
|
||||
"",
|
||||
`const val GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}`,
|
||||
|
||||
@@ -299,6 +299,8 @@ type OpenClawCodingToolsOptions = {
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
/** Runtime-scoped explicit allowlist used to materialize matching plugin tools. */
|
||||
runtimeToolAllowlist?: string[];
|
||||
/** Host-prepared proof that this exact session can request Gateway publication. */
|
||||
githubPublicationAvailable?: boolean;
|
||||
/** True when runtimeToolAllowlist is real parent authority that child sessions inherit. */
|
||||
inheritRuntimeToolAllowlist?: boolean;
|
||||
/** Mutable spawn capability snapshot refreshed after late-bound runtime tools are authorized. */
|
||||
@@ -810,6 +812,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
|
||||
pluginToolAllowlist,
|
||||
pluginToolDenylist,
|
||||
runtimeToolAllowlist: options?.runtimeToolAllowlist,
|
||||
githubPublicationAvailable: options?.githubPublicationAvailable,
|
||||
cronCreatorToolAllowlist,
|
||||
cronCreatorToolAllowlistCaptureRef,
|
||||
resolveCronCreatorToolAuthority: cronCreatorAuthorityResolver,
|
||||
|
||||
@@ -31,6 +31,7 @@ const CORE_TOOL_FACTORY_DESCRIPTORS = [
|
||||
{ name: "dashboard", family: "openclaw" },
|
||||
{ name: "gateway", family: "openclaw" },
|
||||
{ name: "get_goal", family: "openclaw" },
|
||||
{ name: "github_publish", family: "openclaw" },
|
||||
{ name: "heartbeat_respond", family: "openclaw" },
|
||||
{ name: "view_image", family: "openclaw" },
|
||||
{ name: "image_generate", family: "openclaw" },
|
||||
|
||||
@@ -292,6 +292,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
|
||||
spawnWorkspaceDir,
|
||||
config: toolSearchRuntimeConfig,
|
||||
webSearchEnabled: attempt.toolOverrides?.webSearch !== false,
|
||||
githubPublicationAvailable: attempt.githubPublicationAvailable,
|
||||
abortSignal: params.runAbortController.signal,
|
||||
modelProvider: attempt.provider,
|
||||
modelId: attempt.modelId,
|
||||
|
||||
@@ -169,6 +169,8 @@ export type RunEmbeddedAgentParams = {
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
/** Host-prepared proof that the exact session can request Gateway publication. */
|
||||
githubPublicationAvailable?: boolean;
|
||||
swarmCollector?: boolean;
|
||||
swarmOutputSchema?: Record<string, unknown>;
|
||||
/** Restrict this reconstructed run to restart-safe tools. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
appendGitCoauthorContext,
|
||||
prepareGitCoauthorAttribution,
|
||||
resolveGitCoauthorAttribution,
|
||||
} from "./git-coauthor-attribution.js";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -103,6 +104,22 @@ describe("Git co-author attribution", () => {
|
||||
sessionKey,
|
||||
storePath: state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"),
|
||||
});
|
||||
const structured = resolveGitCoauthorAttribution({
|
||||
agentId: "main",
|
||||
config: {
|
||||
tools: {
|
||||
github: {
|
||||
profileId: "ghp_11111111111111111111111111111111",
|
||||
gitAuthor: { email: "custom-author@example.test" },
|
||||
},
|
||||
},
|
||||
},
|
||||
excludeAccountId: 30,
|
||||
currentProfileId: current.id,
|
||||
env: state.env,
|
||||
sessionKey,
|
||||
storePath: state.statePath("agents", "main", "agent", "openclaw-agent.sqlite"),
|
||||
});
|
||||
|
||||
const modelPrompt = appendGitCoauthorContext("commit this", attribution);
|
||||
expect(modelPrompt).toContain(
|
||||
@@ -114,6 +131,14 @@ describe("Git co-author attribution", () => {
|
||||
);
|
||||
expect(modelPrompt).not.toContain("Co-authored-by: opted-out");
|
||||
expect(modelPrompt).not.toContain("Co-authored-by: legacy");
|
||||
expect(structured).toMatchObject({
|
||||
logins: ["grace", "current", "ada"],
|
||||
trailers: [
|
||||
"Co-authored-by: grace <10+grace@users.noreply.github.com>",
|
||||
"Co-authored-by: current <15+current@users.noreply.github.com>",
|
||||
"Co-authored-by: ada <20+ada@users.noreply.github.com>",
|
||||
],
|
||||
});
|
||||
expect(modelPrompt).toContain(
|
||||
"3 eligible profile participant(s) have no enabled Git co-author credit and were omitted",
|
||||
);
|
||||
|
||||
@@ -12,10 +12,29 @@ export function prepareGitCoauthorAttribution(params: {
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
currentProfileId?: string;
|
||||
excludeAccountId?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
}): string | undefined {
|
||||
return resolveGitCoauthorAttribution(params)?.prompt;
|
||||
}
|
||||
|
||||
type GitCoauthorAttribution = {
|
||||
trailers: string[];
|
||||
logins: string[];
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export function resolveGitCoauthorAttribution(params: {
|
||||
agentId: string;
|
||||
config: OpenClawConfig;
|
||||
currentProfileId?: string;
|
||||
excludeAccountId?: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
}): GitCoauthorAttribution | undefined {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -37,6 +56,7 @@ export function prepareGitCoauthorAttribution(params: {
|
||||
resolveConfiguredGitHubToolIdentity({ ...params, scope: "system" });
|
||||
const primaryEmail = primaryIdentity?.gitAuthor?.email?.trim().toLowerCase();
|
||||
const trailers = new Map<number, string>();
|
||||
const logins = new Map<number, string>();
|
||||
let withoutCredit = 0;
|
||||
let unresolved = 0;
|
||||
let primaryAuthor = 0;
|
||||
@@ -50,12 +70,17 @@ export function prepareGitCoauthorAttribution(params: {
|
||||
withoutCredit += 1;
|
||||
continue;
|
||||
}
|
||||
if (identity.accountId === params.excludeAccountId) {
|
||||
primaryAuthor += 1;
|
||||
continue;
|
||||
}
|
||||
const noreplyEmail = `${identity.accountId}+${identity.login}@users.noreply.github.com`;
|
||||
if (noreplyEmail.toLowerCase() === primaryEmail) {
|
||||
primaryAuthor += 1;
|
||||
continue;
|
||||
}
|
||||
trailers.set(identity.accountId, `Co-authored-by: ${identity.login} <${noreplyEmail}>`);
|
||||
logins.set(identity.accountId, identity.login);
|
||||
}
|
||||
|
||||
const exactTrailers = [...trailers.entries()]
|
||||
@@ -82,5 +107,11 @@ export function prepareGitCoauthorAttribution(params: {
|
||||
? `${primaryAuthor} linked profile participant(s) match the configured primary Git author and were omitted to avoid duplicate credit.`
|
||||
: undefined,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
return [guidance, ...notices].join("\n");
|
||||
return {
|
||||
trailers: exactTrailers,
|
||||
logins: [...logins.entries()]
|
||||
.toSorted(([left], [right]) => left - right)
|
||||
.map(([, login]) => login),
|
||||
prompt: [guidance, ...notices].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { resolveCommandEnv } from "../process/exec-spawn.js";
|
||||
|
||||
const processMocks = vi.hoisted(() => ({ runCommandBuffered: vi.fn() }));
|
||||
|
||||
@@ -9,6 +10,8 @@ vi.mock("../process/exec.js", () => ({ runCommandBuffered: processMocks.runComma
|
||||
|
||||
import {
|
||||
installManagedGitHubProfile,
|
||||
matchesPreparedGitHubPublicationIdentity,
|
||||
prepareGitHubPublicationIdentity,
|
||||
prepareGitHubToolEnvironment,
|
||||
resolveGitHubToolIdentityStatus,
|
||||
resolveManagedGitHubAgentKey,
|
||||
@@ -147,7 +150,7 @@ describe("GitHub tool identity", () => {
|
||||
expect(storeScrub.excludedStoreNames).toEqual(["PREVIEW_STORE_TOKEN"]);
|
||||
});
|
||||
|
||||
it("preserves ambient credentials for native identity", () => {
|
||||
it("preserves ambient credentials for native identity", async () => {
|
||||
const native = prepareGitHubToolEnvironment({
|
||||
config: {},
|
||||
agentId: "main",
|
||||
@@ -167,6 +170,18 @@ describe("GitHub tool identity", () => {
|
||||
credentialScrubEnv: {},
|
||||
managedLocalIdentity: false,
|
||||
});
|
||||
processMocks.runCommandBuffered.mockResolvedValue(
|
||||
commandResult('{"id":101,"login":"native-user","avatarUrl":null}\n'),
|
||||
);
|
||||
const publication = await prepareGitHubPublicationIdentity({
|
||||
config: {},
|
||||
agentId: "main",
|
||||
env: { GH_TOKEN: "test-token", GITHUB_TOKEN: "fallback-token" },
|
||||
});
|
||||
expect(publication.env).toMatchObject({
|
||||
GH_TOKEN: "test-token",
|
||||
GITHUB_TOKEN: "fallback-token",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -243,7 +258,7 @@ describe("GitHub tool identity", () => {
|
||||
const workspace = tempDirs.make("openclaw-github-workspace-");
|
||||
processMocks.runCommandBuffered.mockImplementation(async (argv: string[]) =>
|
||||
argv[0] === "gh"
|
||||
? commandResult('{"login":"native-user","avatarUrl":null}\n')
|
||||
? commandResult('{"id":101,"login":"native-user","avatarUrl":null}\n')
|
||||
: commandResult(),
|
||||
);
|
||||
|
||||
@@ -262,6 +277,115 @@ describe("GitHub tool identity", () => {
|
||||
expect(gitCall?.[1]).toMatchObject({ cwd: workspace });
|
||||
});
|
||||
|
||||
it("removes ambient tokens from the actual managed publication child environment", async () => {
|
||||
const root = tempDirs.make("openclaw-github-publication-env-");
|
||||
const profileId = "ghp_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
const env = {
|
||||
OPENCLAW_STATE_DIR: root,
|
||||
GH_TOKEN: "ambient-primary",
|
||||
GITHUB_TOKEN: "ambient-fallback",
|
||||
PREVIEW_SERVICE_TOKEN: "preview-only",
|
||||
};
|
||||
const profileDir = resolveManagedGitHubProfileDir({
|
||||
agentId: "main",
|
||||
scope: "system",
|
||||
profileId,
|
||||
env,
|
||||
});
|
||||
await fs.mkdir(profileDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(path.join(profileDir, "hosts.yml"), "github.com:\n", { mode: 0o600 });
|
||||
processMocks.runCommandBuffered.mockResolvedValue(
|
||||
commandResult('{"id":202,"login":"managed-user","avatarUrl":null}\n'),
|
||||
);
|
||||
|
||||
const identity = await prepareGitHubPublicationIdentity({
|
||||
config: {
|
||||
tools: { github: { profileId } },
|
||||
gateway: { controlUi: { github: { token: "resolved-preview-token" } } },
|
||||
},
|
||||
sourceConfig: {
|
||||
tools: { github: { profileId } },
|
||||
gateway: {
|
||||
controlUi: {
|
||||
github: {
|
||||
token: { source: "env", provider: "default", id: "PREVIEW_SERVICE_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
env,
|
||||
});
|
||||
const childEnv = resolveCommandEnv({
|
||||
argv: ["gh", "api", "user"],
|
||||
baseEnv: env,
|
||||
env: identity.env,
|
||||
});
|
||||
|
||||
expect(identity.env).toMatchObject({
|
||||
GH_CONFIG_DIR: profileDir,
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
PREVIEW_SERVICE_TOKEN: undefined,
|
||||
});
|
||||
expect(childEnv.GH_TOKEN).toBeUndefined();
|
||||
expect(childEnv.GITHUB_TOKEN).toBeUndefined();
|
||||
expect(childEnv.GH_CONFIG_DIR).toBe(profileDir);
|
||||
expect(childEnv.PREVIEW_SERVICE_TOKEN).toBeUndefined();
|
||||
expect(
|
||||
matchesPreparedGitHubPublicationIdentity({
|
||||
config: { tools: { github: { profileId } } },
|
||||
agentId: "main",
|
||||
identity,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesPreparedGitHubPublicationIdentity({
|
||||
config: {
|
||||
tools: { github: { profileId: "ghp_cccccccccccccccccccccccccccccccc" } },
|
||||
},
|
||||
agentId: "main",
|
||||
identity,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(processMocks.runCommandBuffered).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(["gh", "api", "user"]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
GH_CONFIG_DIR: profileDir,
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a source-owned preview token from native publication commands", async () => {
|
||||
processMocks.runCommandBuffered.mockResolvedValue(
|
||||
commandResult('{"id":101,"login":"native-user","avatarUrl":null}\n'),
|
||||
);
|
||||
const identity = await prepareGitHubPublicationIdentity({
|
||||
config: { gateway: { controlUi: { github: { token: "resolved-preview-token" } } } },
|
||||
sourceConfig: {
|
||||
gateway: {
|
||||
controlUi: {
|
||||
github: {
|
||||
token: { source: "env", provider: "default", id: "GH_TOKEN" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentId: "main",
|
||||
env: { GH_TOKEN: "preview-only", NATIVE_GH_CONFIG: "available" },
|
||||
});
|
||||
|
||||
expect(identity.source).toBe("system-detected");
|
||||
expect(identity.env).toMatchObject({
|
||||
GH_TOKEN: undefined,
|
||||
NATIVE_GH_CONFIG: "available",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "invalid credential",
|
||||
@@ -332,7 +456,7 @@ describe("GitHub tool identity", () => {
|
||||
return commandResult();
|
||||
}
|
||||
return commandResult(
|
||||
'{"login":"managed-user","avatarUrl":"https://example.test/avatar"}\n',
|
||||
'{"id":202,"login":"managed-user","avatarUrl":"https://example.test/avatar"}\n',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -343,7 +467,11 @@ describe("GitHub tool identity", () => {
|
||||
commitConfig: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ login: "managed-user", avatarUrl: "https://example.test/avatar" });
|
||||
expect(result).toEqual({
|
||||
accountId: 202,
|
||||
login: "managed-user",
|
||||
avatarUrl: "https://example.test/avatar",
|
||||
});
|
||||
expect(calls[0]?.argv).not.toContain("test-managed-token");
|
||||
expect(calls[0]?.input).toBe("test-managed-token\n");
|
||||
for (const call of calls) {
|
||||
@@ -374,7 +502,7 @@ describe("GitHub tool identity", () => {
|
||||
);
|
||||
return commandResult();
|
||||
}
|
||||
return commandResult('{"login":"managed-user","avatarUrl":null}\n');
|
||||
return commandResult('{"id":202,"login":"managed-user","avatarUrl":null}\n');
|
||||
},
|
||||
);
|
||||
const commitConfig = vi.fn(async () => {
|
||||
@@ -421,7 +549,7 @@ describe("GitHub tool identity", () => {
|
||||
);
|
||||
return commandResult();
|
||||
}
|
||||
return commandResult('{"login":"managed-user","avatarUrl":null}\n');
|
||||
return commandResult('{"id":202,"login":"managed-user","avatarUrl":null}\n');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const PROFILE_COMMAND_TIMEOUT_MS = 15_000;
|
||||
const PROFILE_OUTPUT_LIMIT_BYTES = 32 * 1024;
|
||||
const MANAGED_GITHUB_ROOT_SEGMENTS = ["credentials", "github"] as const;
|
||||
|
||||
type GitHubToolAccount = { login: string; avatarUrl: string | null };
|
||||
type GitHubToolAccount = { accountId: number; login: string; avatarUrl: string | null };
|
||||
|
||||
export function createManagedGitHubProfileId(): string {
|
||||
return `ghp_${randomBytes(16).toString("hex")}`;
|
||||
@@ -177,11 +177,13 @@ function parseAccount(stdout: Buffer): GitHubToolAccount | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const accountId = value.id;
|
||||
const login = readNonBlankString(value.login)?.trim();
|
||||
if (!login) {
|
||||
if (!Number.isSafeInteger(accountId) || Number(accountId) <= 0 || !login) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
accountId: Number(accountId),
|
||||
login,
|
||||
avatarUrl: readNonBlankString(value.avatarUrl)?.trim() ?? null,
|
||||
};
|
||||
@@ -199,11 +201,14 @@ async function probeAccount(env?: NodeJS.ProcessEnv) {
|
||||
"--hostname",
|
||||
GITHUB_HOST,
|
||||
"--jq",
|
||||
"{login: .login, avatarUrl: .avatar_url}",
|
||||
"{id: .id, login: .login, avatarUrl: .avatar_url}",
|
||||
],
|
||||
env,
|
||||
);
|
||||
return { result, account: result.code === 0 ? parseAccount(result.stdout) : undefined };
|
||||
return {
|
||||
result,
|
||||
account: result.code === 0 ? parseAccount(result.stdout) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function isRateLimitedProbe(result: Awaited<ReturnType<typeof runIdentityCommand>>): boolean {
|
||||
@@ -307,7 +312,7 @@ export async function resolveGitHubToolIdentityStatus(params: {
|
||||
agentId: params.agentId,
|
||||
source: identity.source,
|
||||
credentialState,
|
||||
account,
|
||||
account: account ? { login: account.login, avatarUrl: account.avatarUrl } : null,
|
||||
gitAuthor: author,
|
||||
evidence: account
|
||||
? "github-api"
|
||||
@@ -319,6 +324,69 @@ export async function resolveGitHubToolIdentityStatus(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export type PreparedGitHubPublicationIdentity = Readonly<{
|
||||
source: "system-detected" | "system-configured" | "agent-override";
|
||||
profileId?: string;
|
||||
account: GitHubToolAccount;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}>;
|
||||
|
||||
/** Confirms the current config still selects the prepared publication profile. */
|
||||
export function matchesPreparedGitHubPublicationIdentity(params: {
|
||||
config: OpenClawConfig;
|
||||
agentId: string;
|
||||
identity: PreparedGitHubPublicationIdentity;
|
||||
}): boolean {
|
||||
const current = resolveGitHubToolIdentity(params);
|
||||
return (
|
||||
current.source === params.identity.source &&
|
||||
(current.source === "system-detected" || current.config.profileId === params.identity.profileId)
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves a Gateway-owned publication identity without exposing its child environment. */
|
||||
export async function prepareGitHubPublicationIdentity(params: {
|
||||
config: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
agentId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<PreparedGitHubPublicationIdentity> {
|
||||
const identity = resolveGitHubToolIdentity(params);
|
||||
const managed = identity.source !== "system-detected";
|
||||
if (managed && !(await isPrivateManagedProfile(identity.profileDir))) {
|
||||
throw new Error("The configured GitHub identity profile is unavailable.");
|
||||
}
|
||||
const hostEnv = params.env ?? process.env;
|
||||
const prepared = prepareGitHubToolEnvironment({
|
||||
config: params.config,
|
||||
sourceConfig: params.sourceConfig,
|
||||
agentId: params.agentId,
|
||||
env: hostEnv,
|
||||
});
|
||||
const directScrubEnv = Object.fromEntries(
|
||||
Object.keys(prepared.credentialScrubEnv).map((name) => [name, undefined]),
|
||||
);
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...hostEnv,
|
||||
...directScrubEnv,
|
||||
...prepared.localIdentityEnv,
|
||||
// Direct gh calls must not see empty token variables: gh treats them as
|
||||
// authoritative and will not fall through to a native or managed profile.
|
||||
...(managed ? { GH_TOKEN: undefined, GITHUB_TOKEN: undefined } : {}),
|
||||
GH_PROMPT_DISABLED: "1",
|
||||
};
|
||||
const probe = await probeAccount(env);
|
||||
if (!probe.account) {
|
||||
throw new Error("The effective GitHub identity could not be verified.");
|
||||
}
|
||||
return Object.freeze({
|
||||
source: identity.source,
|
||||
...(managed ? { profileId: identity.config.profileId } : {}),
|
||||
account: probe.account,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
async function makePrivateTree(root: string): Promise<void> {
|
||||
await fs.chmod(root, 0o700);
|
||||
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||
@@ -337,7 +405,7 @@ async function makePrivateTree(root: string): Promise<void> {
|
||||
export async function installManagedGitHubProfile(params: {
|
||||
profileDir: string;
|
||||
token: string;
|
||||
commitConfig: () => Promise<void>;
|
||||
commitConfig: (account: GitHubToolAccount) => Promise<void>;
|
||||
}): Promise<GitHubToolAccount> {
|
||||
const token = params.token.trim();
|
||||
if (!token || /[\r\n]/u.test(token)) {
|
||||
@@ -372,7 +440,7 @@ export async function installManagedGitHubProfile(params: {
|
||||
await makePrivateTree(stagedProfile);
|
||||
await fs.rename(stagedProfile, params.profileDir);
|
||||
published = true;
|
||||
await params.commitConfig();
|
||||
await params.commitConfig(verified.account);
|
||||
committed = true;
|
||||
return verified.account;
|
||||
} finally {
|
||||
|
||||
@@ -1020,6 +1020,16 @@ describe("gateway client capability tool filtering", () => {
|
||||
expect(hasTool(createOpenClawTools({ clientCaps: ["ui-commands"] }), "screen")).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes GitHub publication only from a prepared session capability", () => {
|
||||
expect(hasTool(createOpenClawTools(), "github_publish")).toBe(false);
|
||||
expect(
|
||||
hasTool(createOpenClawTools({ githubPublicationAvailable: false }), "github_publish"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
hasTool(createOpenClawTools({ githubPublicationAvailable: true }), "github_publish"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("omits host UI runtime tools for sandboxed agents", () => {
|
||||
expect(hasTool(createOpenClawTools({ agentSessionKey: "agent:main:main" }), "terminal")).toBe(
|
||||
true,
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type {
|
||||
SourceReplyDeliveryMode,
|
||||
TaskSuggestionDeliveryMode,
|
||||
} from "../auto-reply/get-reply-options.types.js";
|
||||
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
|
||||
import { createShowWidgetTool, hasRegisteredShowWidgetKinds } from "../canvas/widget-tool.js";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
|
||||
import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js";
|
||||
import { selectApplicableRuntimeConfig } from "../config/config.js";
|
||||
import { resolveControlUiSessionLinkBase } from "../config/control-ui-link-base.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { isEmbeddedMode } from "../infra/embedded-mode.js";
|
||||
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
|
||||
import { getActiveRuntimeWebToolsMetadataFromState } from "../secrets/runtime-web-tools-state.js";
|
||||
@@ -21,7 +13,6 @@ import {
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
wrapToolWithBeforeToolCallHook,
|
||||
} from "./agent-tools.before-tool-call.js";
|
||||
import type { ConversationRecallContext } from "./conversation-recall.types.js";
|
||||
import { resolveOpenClawPluginToolsForOptions } from "./openclaw-plugin-tools.js";
|
||||
import { filterToolsByClientCaps } from "./openclaw-tools.client-caps.js";
|
||||
import {
|
||||
@@ -31,7 +22,6 @@ import {
|
||||
resolveOptionalMediaToolFactoryPlan,
|
||||
} from "./openclaw-tools.media-factory-plan.js";
|
||||
import { createMediaGenerationAsyncStartCallback } from "./openclaw-tools.media-yield.js";
|
||||
import type { ModelAwareToolContext } from "./openclaw-tools.model-context.js";
|
||||
import { applyNodesToolWorkspaceGuard } from "./openclaw-tools.nodes-workspace-guard.js";
|
||||
import {
|
||||
collectPresentOpenClawTools,
|
||||
@@ -41,10 +31,8 @@ import {
|
||||
import { createRequesterYieldCallback } from "./openclaw-tools.requester-yield.js";
|
||||
import { createOpenClawSwarmToolGroups } from "./openclaw-tools.swarm.js";
|
||||
import { resolveTranscriptsTool } from "./openclaw-tools.transcripts.js";
|
||||
import type { OpenClawToolsOptions } from "./openclaw-tools.types.js";
|
||||
import { resolveWidgetPresentationForRun } from "./openclaw-tools.widget-presentation.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
import type { SpawnedToolContext } from "./spawned-context.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.js";
|
||||
import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js";
|
||||
import { createAgentsListTool } from "./tools/agents-list-tool.js";
|
||||
import { createAskUserTool } from "./tools/ask-user-tool.js";
|
||||
@@ -56,11 +44,11 @@ import {
|
||||
createConversationsTurnTool,
|
||||
} from "./tools/conversation-tools.js";
|
||||
import { createCronTool } from "./tools/cron-tool.js";
|
||||
import type { CronToolOptions } from "./tools/cron-tool.types.js";
|
||||
import { createDashboardTool } from "./tools/dashboard-tool.js";
|
||||
import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js";
|
||||
import { createGatewayToolCallerWrapper } from "./tools/gateway-caller-context.js";
|
||||
import { createGatewayTool } from "./tools/gateway-tool.js";
|
||||
import { createGitHubPublishTool } from "./tools/github-publish-tool.js";
|
||||
import {
|
||||
createCreateGoalTool,
|
||||
createGetGoalTool,
|
||||
@@ -97,134 +85,7 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
||||
import { resolveWorkspaceRoot } from "./workspace-dir.js";
|
||||
|
||||
export { filterToolsByClientCaps } from "./openclaw-tools.client-caps.js";
|
||||
export function createOpenClawTools(
|
||||
options?: {
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
/** Durable store key when it differs from the sandbox/policy session key. */
|
||||
runSessionKey?: string;
|
||||
agentChannel?: string;
|
||||
runId?: string;
|
||||
agentAccountId?: string;
|
||||
/** Trusted account used for authorization; delivery keeps agentAccountId. */
|
||||
gatewayCallerAccountId?: string;
|
||||
gatewayCallerChannel?: string | null;
|
||||
/** True only for explicit server-authored local scheduled provenance. */
|
||||
gatewayCallerLocal?: boolean;
|
||||
/** True only for a validated scheduled tool policy. */
|
||||
gatewayCallerScheduled?: boolean;
|
||||
/** Delivery target for topic/thread routing. */
|
||||
agentTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
agentThreadId?: string | number;
|
||||
/** Trusted platform-native conversation id for the active inbound turn. */
|
||||
nativeChannelId?: string;
|
||||
/** Opaque host-issued capability for current-turn channel message actions. */
|
||||
messageActionTurnCapability?: string;
|
||||
sandboxRoot?: string;
|
||||
sandboxContainerWorkdir?: string;
|
||||
sandboxFsBridge?: SandboxFsBridge;
|
||||
fsPolicy?: ToolFsPolicy;
|
||||
sandboxed?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
webFetchHostnameAllowlistRef?: { value?: string[] };
|
||||
webSearchEnabled?: boolean;
|
||||
/** Capabilities declared by the gateway client that originated this run. */
|
||||
clientCaps?: string[];
|
||||
pluginToolAllowlist?: string[];
|
||||
pluginToolDenylist?: string[];
|
||||
runtimeToolAllowlist?: string[];
|
||||
/** Effective caller tool surface to persist on isolated cron agentTurn jobs. */
|
||||
cronCreatorToolAllowlist?: CronToolOptions["creatorToolAllowlist"];
|
||||
cronCreatorToolAllowlistCaptureRef?: CronToolOptions["creatorToolAllowlistCaptureRef"];
|
||||
resolveCronCreatorToolAuthority?: CronToolOptions["resolveCreatorToolAuthority"];
|
||||
cronCreatorAuthorityUnavailableReason?: CronToolOptions["creatorAuthorityUnavailableReason"];
|
||||
/** Current channel ID for auto-threading. */
|
||||
currentChannelId?: string;
|
||||
/** Trusted normalized conversation kind for the active inbound turn. */
|
||||
currentChatType?: ChatType;
|
||||
/** Routable target for the current conversation when it differs from the native channel ID. */
|
||||
currentMessagingTarget?: string;
|
||||
/** Current thread timestamp for auto-threading. */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks. */
|
||||
currentMessageId?: string | number;
|
||||
/** True when the current inbound turn carried audio media. */
|
||||
currentInboundAudio?: boolean;
|
||||
/** Dynamic audio state for runs that can accept steered input after tool creation. */
|
||||
hasCurrentInboundAudio?: () => boolean;
|
||||
/** Reply-to mode for auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** Fail closed instead of posting same-channel thread-originated replies at the root. */
|
||||
sameChannelThreadRequired?: boolean;
|
||||
/** Mutable model-context generation used to expire screenshot coordinate frames. */
|
||||
computerContextEpoch?: { value: number };
|
||||
/** Registers run-owned cleanup for tools that hold node resources. */
|
||||
registerRunCleanup?: (cleanup: (reason: string) => Promise<void>) => void;
|
||||
skillWorkshop?: import("../skills/workshop/types.js").SkillWorkshopRunOptions;
|
||||
/** If true, nodes action="invoke" can call media-returning commands directly. */
|
||||
allowMediaInvokeCommands?: boolean;
|
||||
/** Trusted sender identity bit for channel action auth. */
|
||||
senderIsOwner?: boolean;
|
||||
/** Server-owned operation-local origin for conversation-read visibility policy. */
|
||||
conversationReadOrigin?: ConversationReadInvocationOrigin;
|
||||
/** Restrict cron operations to the active cron job's self-scoped surface. */
|
||||
cronSelfRemoveOnlyJobId?: string;
|
||||
/** Require explicit message targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** Visible source replies must be sent through the message tool when set to message_tool_only. */
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
/** Process-local completion authority restricted to the current source conversation. */
|
||||
sourceReplyOnly?: boolean;
|
||||
/** Action sink available for model-proposed follow-up tasks. */
|
||||
taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
|
||||
inboundEventKind?: InboundEventKind;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
swarmCollector?: boolean;
|
||||
swarmOutputSchema?: Record<string, unknown>;
|
||||
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
|
||||
enableHeartbeatTool?: boolean;
|
||||
/** If true, skip plugin tool resolution and return only shipped core tools. */
|
||||
disablePluginTools?: boolean;
|
||||
/**
|
||||
* Wrap returned tools with the before_tool_call hook at construction time.
|
||||
* Defaults to true; callers that already enforce the hook at a later shared
|
||||
* boundary should opt out explicitly.
|
||||
*/
|
||||
wrapBeforeToolCallHook?: boolean;
|
||||
/** Override or extend the default hook context used by construction-time wrapping. */
|
||||
beforeToolCallHookContext?: HookContext;
|
||||
/** Records hot-path tool-prep stages for reply startup diagnostics. */
|
||||
recordToolPrepStage?: (name: string) => void;
|
||||
/** Trusted sender id from inbound context (not tool args). */
|
||||
requesterSenderId?: string | null;
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
/** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */
|
||||
conversationRecall?: ConversationRecallContext;
|
||||
/** One-shot local CLI runs release plugin-owned resources after their result. */
|
||||
oneShotCliRun?: boolean;
|
||||
/**
|
||||
* Workspace directory to pass to spawned subagents for inheritance.
|
||||
* Defaults to workspaceDir. Use this to pass the actual agent workspace when the
|
||||
* session itself is running in a copied-workspace sandbox (`ro` or `none`) so
|
||||
* subagents inherit the real workspace path instead of the sandbox copy.
|
||||
*/
|
||||
spawnWorkspaceDir?: string;
|
||||
/** Current runtime directory used as the default project for follow-up suggestions. */
|
||||
cwd?: string;
|
||||
onYield?: (message: string, acknowledgment?: string) => Promise<void> | void;
|
||||
claimYieldCompletion?: () => boolean | Promise<boolean>;
|
||||
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
} & SpawnedToolContext &
|
||||
ModelAwareToolContext,
|
||||
): AnyAgentTool[] {
|
||||
export function createOpenClawTools(options?: OpenClawToolsOptions): AnyAgentTool[] {
|
||||
const resolvedConfig = options?.config;
|
||||
const activeProjectKeys = options?.preparedModelRuntime?.activeProjectKeys ?? [];
|
||||
const runtimeSnapshot = getActiveSecretsRuntimeConfigSnapshot();
|
||||
@@ -546,6 +407,7 @@ export function createOpenClawTools(
|
||||
agentId: sessionAgentId,
|
||||
agentAccountId: options?.agentAccountId,
|
||||
}),
|
||||
...(options?.githubPublicationAvailable === true ? [createGitHubPublishTool()] : []),
|
||||
...collectPresentOpenClawTools([transcriptsTool]),
|
||||
...collectPresentOpenClawTools([imageGenerateTool, musicGenerateTool, videoGenerateTool]),
|
||||
...(embedded
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
SourceReplyDeliveryMode,
|
||||
TaskSuggestionDeliveryMode,
|
||||
} from "../auto-reply/get-reply-options.types.js";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
import type { InboundEventKind } from "../channels/inbound-event/kind.js";
|
||||
import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SkillWorkshopRunOptions } from "../skills/workshop/types.js";
|
||||
import type { HookContext } from "./agent-tools.before-tool-call.js";
|
||||
import type { ConversationRecallContext } from "./conversation-recall.types.js";
|
||||
import type { ModelAwareToolContext } from "./openclaw-tools.model-context.js";
|
||||
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
|
||||
import type { SpawnedToolContext } from "./spawned-context.js";
|
||||
import type { ToolFsPolicy } from "./tool-fs-policy.js";
|
||||
import type { CronToolOptions } from "./tools/cron-tool.types.js";
|
||||
|
||||
export type OpenClawToolsOptions = {
|
||||
sandboxBrowserBridgeUrl?: string;
|
||||
allowHostBrowserControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
toolBindings?: Readonly<Record<string, unknown>>;
|
||||
/** Durable store key when it differs from the sandbox/policy session key. */
|
||||
runSessionKey?: string;
|
||||
agentChannel?: string;
|
||||
runId?: string;
|
||||
agentAccountId?: string;
|
||||
/** Trusted account used for authorization; delivery keeps agentAccountId. */
|
||||
gatewayCallerAccountId?: string;
|
||||
gatewayCallerChannel?: string | null;
|
||||
/** True only for explicit server-authored local scheduled provenance. */
|
||||
gatewayCallerLocal?: boolean;
|
||||
/** True only for a validated scheduled tool policy. */
|
||||
gatewayCallerScheduled?: boolean;
|
||||
/** Delivery target for topic/thread routing. */
|
||||
agentTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
agentThreadId?: string | number;
|
||||
/** Trusted platform-native conversation id for the active inbound turn. */
|
||||
nativeChannelId?: string;
|
||||
/** Opaque host-issued capability for current-turn channel message actions. */
|
||||
messageActionTurnCapability?: string;
|
||||
sandboxRoot?: string;
|
||||
sandboxContainerWorkdir?: string;
|
||||
sandboxFsBridge?: SandboxFsBridge;
|
||||
fsPolicy?: ToolFsPolicy;
|
||||
sandboxed?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
webFetchHostnameAllowlistRef?: { value?: string[] };
|
||||
webSearchEnabled?: boolean;
|
||||
/** Capabilities declared by the gateway client that originated this run. */
|
||||
clientCaps?: string[];
|
||||
pluginToolAllowlist?: string[];
|
||||
pluginToolDenylist?: string[];
|
||||
runtimeToolAllowlist?: string[];
|
||||
/** Host-prepared proof that this exact session can request Gateway publication. */
|
||||
githubPublicationAvailable?: boolean;
|
||||
/** Effective caller tool surface to persist on isolated cron agentTurn jobs. */
|
||||
cronCreatorToolAllowlist?: CronToolOptions["creatorToolAllowlist"];
|
||||
cronCreatorToolAllowlistCaptureRef?: CronToolOptions["creatorToolAllowlistCaptureRef"];
|
||||
resolveCronCreatorToolAuthority?: CronToolOptions["resolveCreatorToolAuthority"];
|
||||
cronCreatorAuthorityUnavailableReason?: CronToolOptions["creatorAuthorityUnavailableReason"];
|
||||
/** Current channel ID for auto-threading. */
|
||||
currentChannelId?: string;
|
||||
/** Trusted normalized conversation kind for the active inbound turn. */
|
||||
currentChatType?: ChatType;
|
||||
/** Routable target for the current conversation when it differs from the native channel ID. */
|
||||
currentMessagingTarget?: string;
|
||||
/** Current thread timestamp for auto-threading. */
|
||||
currentThreadTs?: string;
|
||||
/** Current inbound message id for action fallbacks. */
|
||||
currentMessageId?: string | number;
|
||||
/** True when the current inbound turn carried audio media. */
|
||||
currentInboundAudio?: boolean;
|
||||
/** Dynamic audio state for runs that can accept steered input after tool creation. */
|
||||
hasCurrentInboundAudio?: () => boolean;
|
||||
/** Reply-to mode for auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/** Fail closed instead of posting same-channel thread-originated replies at the root. */
|
||||
sameChannelThreadRequired?: boolean;
|
||||
/** Mutable model-context generation used to expire screenshot coordinate frames. */
|
||||
computerContextEpoch?: { value: number };
|
||||
/** Registers run-owned cleanup for tools that hold node resources. */
|
||||
registerRunCleanup?: (cleanup: (reason: string) => Promise<void>) => void;
|
||||
/** Internal review-run restrictions and proposal provenance. */
|
||||
skillWorkshop?: SkillWorkshopRunOptions;
|
||||
/** If true, nodes action="invoke" can call media-returning commands directly. */
|
||||
allowMediaInvokeCommands?: boolean;
|
||||
/** Trusted sender identity bit for channel action auth. */
|
||||
senderIsOwner?: boolean;
|
||||
/** Server-owned operation-local origin for conversation-read visibility policy. */
|
||||
conversationReadOrigin?: ConversationReadInvocationOrigin;
|
||||
/** Restrict cron operations to the active cron job's self-scoped surface. */
|
||||
cronSelfRemoveOnlyJobId?: string;
|
||||
/** Require explicit message targets (no implicit last-route sends). */
|
||||
requireExplicitMessageTarget?: boolean;
|
||||
/** Visible source replies must be sent through the message tool when set to message_tool_only. */
|
||||
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
|
||||
/** Process-local completion authority restricted to the current source conversation. */
|
||||
sourceReplyOnly?: boolean;
|
||||
/** Action sink available for model-proposed follow-up tasks. */
|
||||
taskSuggestionDeliveryMode?: TaskSuggestionDeliveryMode;
|
||||
inboundEventKind?: InboundEventKind;
|
||||
/** If true, omit the message tool from the tool list. */
|
||||
disableMessageTool?: boolean;
|
||||
swarmCollector?: boolean;
|
||||
swarmOutputSchema?: Record<string, unknown>;
|
||||
/** If true, include the heartbeat response tool for structured heartbeat outcomes. */
|
||||
enableHeartbeatTool?: boolean;
|
||||
/** If true, skip plugin tool resolution and return only shipped core tools. */
|
||||
disablePluginTools?: boolean;
|
||||
/**
|
||||
* Wrap returned tools with the before_tool_call hook at construction time.
|
||||
* Defaults to true; callers that already enforce the hook at a later shared
|
||||
* boundary should opt out explicitly.
|
||||
*/
|
||||
wrapBeforeToolCallHook?: boolean;
|
||||
/** Override or extend the default hook context used by construction-time wrapping. */
|
||||
beforeToolCallHookContext?: HookContext;
|
||||
/** Records hot-path tool-prep stages for reply startup diagnostics. */
|
||||
recordToolPrepStage?: (name: string) => void;
|
||||
/** Trusted sender id from inbound context (not tool args). */
|
||||
requesterSenderId?: string | null;
|
||||
/** Ephemeral session UUID — regenerated on /new and /reset. */
|
||||
sessionId?: string;
|
||||
/** Trusted runtime-only authorization for one bounded cross-conversation recall pass. */
|
||||
conversationRecall?: ConversationRecallContext;
|
||||
/** One-shot local CLI runs release plugin-owned resources after their result. */
|
||||
oneShotCliRun?: boolean;
|
||||
/**
|
||||
* Workspace directory to pass to spawned subagents for inheritance.
|
||||
* Defaults to workspaceDir. Use this to pass the actual agent workspace when the
|
||||
* session itself is running in a copied-workspace sandbox (`ro` or `none`) so
|
||||
* subagents inherit the real workspace path instead of the sandbox copy.
|
||||
*/
|
||||
spawnWorkspaceDir?: string;
|
||||
/** Current runtime directory used as the default project for follow-up suggestions. */
|
||||
cwd?: string;
|
||||
onYield?: (message: string, acknowledgment?: string) => Promise<void> | void;
|
||||
claimYieldCompletion?: () => boolean | Promise<boolean>;
|
||||
/** Allow plugin tools for this tool set to late-bind the gateway subagent. */
|
||||
allowGatewaySubagentBinding?: boolean;
|
||||
} & SpawnedToolContext &
|
||||
ModelAwareToolContext;
|
||||
@@ -30,6 +30,14 @@ describe("tool-catalog", () => {
|
||||
expect(ids({ swarmEnabled: true })).toContain("agents_wait");
|
||||
});
|
||||
|
||||
it("lists GitHub publication only with a prepared session capability", () => {
|
||||
const ids = (config?: Parameters<typeof listCoreToolSections>[0]) =>
|
||||
listCoreToolSections(config).flatMap((section) => section.tools.map((tool) => tool.id));
|
||||
|
||||
expect(ids()).not.toContain("github_publish");
|
||||
expect(ids({ githubPublicationAvailable: true })).toContain("github_publish");
|
||||
});
|
||||
|
||||
it("includes code execution, web tools, and progress_card in the coding profile policy", () => {
|
||||
const policy = requireCoreToolProfilePolicy("coding");
|
||||
expect(policy.allow).toEqual([
|
||||
@@ -54,6 +62,7 @@ describe("tool-catalog", () => {
|
||||
"conversations_turn",
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"github_publish",
|
||||
"agents_wait",
|
||||
"sessions_yield",
|
||||
"subagents",
|
||||
|
||||
@@ -230,6 +230,14 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [
|
||||
profiles: ["coding", "messaging"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "github_publish",
|
||||
label: "github_publish",
|
||||
description: "Publish the reconciled session worktree as a draft GitHub pull request",
|
||||
sectionId: "sessions",
|
||||
profiles: ["coding"],
|
||||
includeInOpenClawGroup: true,
|
||||
},
|
||||
{
|
||||
id: "agents_wait",
|
||||
label: "agents_wait",
|
||||
@@ -559,7 +567,10 @@ export function resolveCoreToolProfilePolicy(profile?: string): ToolProfilePolic
|
||||
}
|
||||
|
||||
/** Lists core tools grouped into UI sections. */
|
||||
export function listCoreToolSections(params?: { swarmEnabled?: boolean }): CoreToolSection[] {
|
||||
export function listCoreToolSections(params?: {
|
||||
swarmEnabled?: boolean;
|
||||
githubPublicationAvailable?: boolean;
|
||||
}): CoreToolSection[] {
|
||||
// Callers resolve the swarm gate and pass the fact in; resolving config here
|
||||
// would couple this ui-shared module to the server graph.
|
||||
const swarmEnabled = params?.swarmEnabled === true;
|
||||
@@ -567,7 +578,10 @@ export function listCoreToolSections(params?: { swarmEnabled?: boolean }): CoreT
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
tools: CORE_TOOL_DEFINITIONS.filter(
|
||||
(tool) => tool.sectionId === section.id && (tool.id !== "agents_wait" || swarmEnabled),
|
||||
(tool) =>
|
||||
tool.sectionId === section.id &&
|
||||
(tool.id !== "agents_wait" || swarmEnabled) &&
|
||||
(tool.id !== "github_publish" || params?.githubPublicationAvailable === true),
|
||||
).map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
|
||||
@@ -354,6 +354,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
|
||||
title: "Session Status",
|
||||
detailKeys: ["sessionKey", "model"],
|
||||
},
|
||||
github_publish: {
|
||||
emoji: "🔀",
|
||||
title: "GitHub Publish",
|
||||
detailKeys: ["title"],
|
||||
},
|
||||
sessions: {
|
||||
emoji: "🗂️",
|
||||
title: "Session Settings",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { createGitHubPublishTool } from "./github-publish-tool.js";
|
||||
import type { InProcessGatewayCaller } from "./in-process-gateway.js";
|
||||
|
||||
describe("github_publish tool", () => {
|
||||
it("binds bounded model intent to the host-owned session", async () => {
|
||||
const callGatewayMock = vi.fn(async () => ({
|
||||
requestId: "publication-1",
|
||||
status: "requested" as const,
|
||||
message: "Publication was accepted.",
|
||||
}));
|
||||
const callGateway = callGatewayMock as InProcessGatewayCaller;
|
||||
const tool = createGitHubPublishTool({ callGateway });
|
||||
|
||||
await withGatewayToolCallerIdentity(
|
||||
{ agentId: "main", sessionKey: "agent:main:host-owned" },
|
||||
async () => await tool.execute("tool-call-1", { title: "Publish the fix" }),
|
||||
);
|
||||
|
||||
expect(callGatewayMock).toHaveBeenCalledWith("sessions.github.publish", {
|
||||
sessionKey: "agent:main:host-owned",
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Publish the fix",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Type } from "typebox";
|
||||
import {
|
||||
GitHubPublicationBodySchema,
|
||||
GitHubPublicationTitleSchema,
|
||||
type SessionGitHubPublicationResult,
|
||||
type SessionGitHubPublishParams,
|
||||
} from "../../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult } from "./common.js";
|
||||
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
|
||||
import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js";
|
||||
|
||||
export function createGitHubPublishTool(
|
||||
options: {
|
||||
callGateway?: InProcessGatewayCaller;
|
||||
} = {},
|
||||
): AnyAgentTool {
|
||||
const callGateway = options.callGateway ?? callInProcessGatewayTool;
|
||||
return {
|
||||
label: "GitHub Publish",
|
||||
name: "github_publish",
|
||||
description:
|
||||
"Publish the current session-owned Git worktree through the Gateway. Call only after the work is complete. On cloud or remote-exec sessions this records a durable request; finish the turn so authoritative reconciliation can complete before the Gateway commits, pushes through an exact HTTPS path, and creates or reuses a draft pull request. Credentials never enter tool arguments or the worker.",
|
||||
parameters: Type.Object(
|
||||
{
|
||||
title: Type.Optional(GitHubPublicationTitleSchema),
|
||||
body: Type.Optional(GitHubPublicationBodySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
execute: async (toolCallId, rawArgs) => {
|
||||
// SAFETY: the tool runtime validates rawArgs against the closed schema above.
|
||||
const input = rawArgs as Omit<SessionGitHubPublishParams, "idempotencyKey" | "sessionKey">;
|
||||
const caller = getGatewayToolCallerIdentity();
|
||||
if (!caller?.sessionKey) {
|
||||
throw new Error("GitHub publication requires the current Gateway session.");
|
||||
}
|
||||
const result = await callGateway<SessionGitHubPublicationResult>("sessions.github.publish", {
|
||||
sessionKey: caller.sessionKey,
|
||||
idempotencyKey: toolCallId,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.body ? { body: input.body } : {}),
|
||||
});
|
||||
return jsonResult(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export async function runEmbeddedFallbackCandidate(params: {
|
||||
notifyUserAboutCompaction: boolean;
|
||||
messageToolDeliveryState: MessageToolDeliveryState;
|
||||
preserveProgressCallbackStartOrder: boolean;
|
||||
githubPublicationAvailable: boolean;
|
||||
presentation: EmbeddedPresentation;
|
||||
timing: AgentTurnTimingTracker;
|
||||
onLifecycleBackstop: (backstop: AgentLifecycleTerminalBackstop) => void;
|
||||
@@ -213,6 +214,7 @@ export async function runEmbeddedFallbackCandidate(params: {
|
||||
const result = await params.timing.measure("embedded_run", () => {
|
||||
const embeddedRunParams: Parameters<typeof runEmbeddedAgent>[0] = {
|
||||
preparedRunAdmission: params.preparedRunAdmission,
|
||||
githubPublicationAvailable: params.githubPublicationAvailable,
|
||||
...embeddedContext,
|
||||
messageActionTurnCapability,
|
||||
lifecycleGeneration: params.getLifecycleGeneration(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isCliProvider } from "../../agents/model-selection.js";
|
||||
import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
|
||||
import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js";
|
||||
import { buildGenericCliContextEngineHostSupport } from "../../context-engine/host-compat.js";
|
||||
import { prepareGitHubPublicationAvailability } from "../../gateway/github-publication-availability.js";
|
||||
import { CommandLane } from "../../process/lanes.js";
|
||||
import type { AgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js";
|
||||
import { resolveFallbackCandidateRun, resolveRunAuthProfile } from "./agent-runner-auth-profile.js";
|
||||
@@ -68,6 +69,7 @@ export async function runAgentFallbackCandidates(params: AgentFallbackCycleParam
|
||||
const bootstrapContextRunKind = turn.opts?.isHeartbeat
|
||||
? ("heartbeat" as const)
|
||||
: ("default" as const);
|
||||
let githubPublicationAvailability: Promise<boolean> | undefined;
|
||||
|
||||
params.timing.logMilestoneIfSlow({
|
||||
runId: params.runId,
|
||||
@@ -275,6 +277,14 @@ export async function runAgentFallbackCandidates(params: AgentFallbackCycleParam
|
||||
}
|
||||
const candidate = await runEmbeddedFallbackCandidate({
|
||||
...common,
|
||||
githubPublicationAvailable: await (githubPublicationAvailability ??=
|
||||
turn.sessionKey && params.effectiveRun.agentId
|
||||
? prepareGitHubPublicationAvailability({
|
||||
sessionId: turn.followupRun.run.sessionId,
|
||||
sessionKey: turn.sessionKey,
|
||||
agentId: params.effectiveRun.agentId,
|
||||
})
|
||||
: Promise.resolve(false)),
|
||||
effectiveRun: params.effectiveRun,
|
||||
sessionRuntimeOverride: runtime.sessionRuntimeOverride,
|
||||
getLifecycleGeneration: () => params.state.lifecycleGeneration,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type SessionPullRequestGitContext,
|
||||
type SessionPullRequestLocalGitDeps,
|
||||
} from "./control-ui-session-prs-local-git.js";
|
||||
import { resolveGitHubForkParent } from "./github-repository-target.js";
|
||||
import { loadGatewaySessionEntryReadOnly } from "./session-utils.js";
|
||||
|
||||
const SUCCESS_CACHE_MS = 60_000;
|
||||
@@ -385,13 +386,7 @@ async function fetchParentRepo(
|
||||
): Promise<{ owner: string; repo: string } | null> {
|
||||
const url = `${GITHUB_API_ORIGIN}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
|
||||
const value = await fetchGitHubJson(url, fetchImpl, token);
|
||||
if (!isRecord(value) || value.fork !== true || !isRecord(value.parent)) {
|
||||
return null;
|
||||
}
|
||||
const parentOwner = isRecord(value.parent.owner) ? value.parent.owner : {};
|
||||
const parentLogin = readOptionalGitHubString(parentOwner, "login");
|
||||
const parentName = readOptionalGitHubString(value.parent, "name");
|
||||
return parentLogin && parentName ? { owner: parentLogin, repo: parentName } : null;
|
||||
return resolveGitHubForkParent(value) ?? null;
|
||||
}
|
||||
|
||||
// Sub-fetch degradation: quota errors abort the whole refresh (so the caller
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
matchesPreparedGitHubPublicationIdentity,
|
||||
prepareGitHubPublicationIdentity,
|
||||
type PreparedGitHubPublicationIdentity,
|
||||
} from "../agents/github-tool-identity.js";
|
||||
import { managedWorktrees } from "../agents/worktrees/service.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
|
||||
import { loadGatewaySessionEntryReadOnly } from "./session-utils.js";
|
||||
|
||||
function publicationConfigSnapshot() {
|
||||
const active = getActiveSecretsRuntimeConfigSnapshot();
|
||||
if (active) {
|
||||
return active;
|
||||
}
|
||||
const config = getRuntimeConfig();
|
||||
return { config, sourceConfig: config };
|
||||
}
|
||||
|
||||
export function currentGitHubPublicationConfig() {
|
||||
return publicationConfigSnapshot().config;
|
||||
}
|
||||
|
||||
export async function prepareCurrentGitHubPublicationIdentity(
|
||||
agentId: string,
|
||||
): Promise<PreparedGitHubPublicationIdentity> {
|
||||
const snapshot = publicationConfigSnapshot();
|
||||
return await prepareGitHubPublicationIdentity({
|
||||
config: snapshot.config,
|
||||
sourceConfig: snapshot.sourceConfig,
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function matchesCurrentGitHubPublicationIdentity(params: {
|
||||
agentId: string;
|
||||
identity: PreparedGitHubPublicationIdentity;
|
||||
}): boolean {
|
||||
return matchesPreparedGitHubPublicationIdentity({
|
||||
config: currentGitHubPublicationConfig(),
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGitHubPublicationWorktreeOwner(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
expected?: { worktreeId: string; repositoryFingerprint: string; branch: string };
|
||||
}) {
|
||||
const loaded = loadGatewaySessionEntryReadOnly(params.sessionKey, { agentId: params.agentId });
|
||||
const entry = loaded.entry;
|
||||
const worktree = managedWorktrees.findLiveByOwner("session", loaded.canonicalKey);
|
||||
if (
|
||||
loaded.agentId !== params.agentId ||
|
||||
loaded.canonicalKey !== params.sessionKey ||
|
||||
entry?.sessionId !== params.sessionId ||
|
||||
entry.archivedAt !== undefined ||
|
||||
!entry.worktree?.id ||
|
||||
!worktree ||
|
||||
worktree.id !== entry.worktree.id ||
|
||||
worktree.ownerKind !== "session" ||
|
||||
worktree.ownerId !== loaded.canonicalKey ||
|
||||
worktree.branch !== entry.worktree.branch ||
|
||||
worktree.repoRoot !== entry.worktree.repoRoot
|
||||
) {
|
||||
throw new Error("GitHub publication session worktree owner changed.");
|
||||
}
|
||||
if (
|
||||
params.expected &&
|
||||
(worktree.id !== params.expected.worktreeId ||
|
||||
worktree.repoFingerprint !== params.expected.repositoryFingerprint ||
|
||||
worktree.branch !== params.expected.branch)
|
||||
) {
|
||||
throw new Error("GitHub publication workspace authority changed.");
|
||||
}
|
||||
return { loaded, worktree };
|
||||
}
|
||||
|
||||
export async function prepareGitHubPublicationAvailability(params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
assertCurrent?: () => boolean;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
if (params.assertCurrent?.() === false) {
|
||||
return false;
|
||||
}
|
||||
resolveGitHubPublicationWorktreeOwner(params);
|
||||
const identity = await prepareCurrentGitHubPublicationIdentity(params.agentId);
|
||||
if (params.assertCurrent?.() === false) {
|
||||
return false;
|
||||
}
|
||||
resolveGitHubPublicationWorktreeOwner(params);
|
||||
return matchesCurrentGitHubPublicationIdentity({ agentId: params.agentId, identity });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import os from "node:os";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
export function githubPublicationBaseLookupArgs(repository: string, baseBranch: string): string[] {
|
||||
return [
|
||||
"gh",
|
||||
"api",
|
||||
"--hostname",
|
||||
"github.com",
|
||||
`repos/${repository}/git/ref/heads/${baseBranch}`,
|
||||
"--jq",
|
||||
"{ref: .ref, sha: .object.sha}",
|
||||
];
|
||||
}
|
||||
|
||||
export function githubPublicationBaseFetchArgs(repository: string, sha: string): string[] {
|
||||
return [
|
||||
"git",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"credential.helper=!gh auth git-credential",
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"maintenance.auto=false",
|
||||
"-c",
|
||||
"gc.auto=0",
|
||||
"fetch",
|
||||
"--no-auto-maintenance",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
"--recurse-submodules=no",
|
||||
"--",
|
||||
`https://github.com/${repository}.git`,
|
||||
sha,
|
||||
];
|
||||
}
|
||||
|
||||
export function githubPublicationBranchCreationArgs(branch: string): string[] {
|
||||
return ["git", "reflog", "show", "--format=%H", "--end-of-options", `refs/heads/${branch}`];
|
||||
}
|
||||
|
||||
export function githubPublicationBaseLineageArgs(ancestor: string, descendant: string): string[] {
|
||||
return ["git", "merge-base", "--is-ancestor", ancestor, descendant];
|
||||
}
|
||||
|
||||
export function githubPublicationUnsafeConfigArgs(scope: "--local" | "--worktree"): string[] {
|
||||
return [
|
||||
"git",
|
||||
"config",
|
||||
scope,
|
||||
"--includes",
|
||||
"--get-regexp",
|
||||
"^(core\\.(alternaterefscommand|askpass|fsmonitor|gitproxy|hookspath|sshcommand|worktree)|credential\\..*helper|filter\\..*|http\\..*|include(if)?\\..*|push\\..*|remote\\..*\\.(proxy|receivepack|uploadpack|vcs)|uploadpack\\.packobjectshook|url\\..*\\.(insteadof|pushinsteadof))$",
|
||||
];
|
||||
}
|
||||
|
||||
export function parseGitHubPublicationBaseBranch(baseRef: string, defaultBranch: string): string {
|
||||
const trimmed = baseRef.trim();
|
||||
if (!trimmed || trimmed === "HEAD" || /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/iu.test(trimmed)) {
|
||||
return defaultBranch;
|
||||
}
|
||||
for (const prefix of ["refs/remotes/origin/", "origin/", "refs/heads/"]) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Returns the authenticated target-base SHA or fails the publication boundary closed. */
|
||||
export function parseGitHubPublicationBaseRef(raw: string, baseBranch: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("GitHub publication workspace base branch could not be verified.");
|
||||
}
|
||||
const ref = isRecord(parsed) ? readNonBlankString(parsed.ref) : undefined;
|
||||
const sha = isRecord(parsed) ? readNonBlankString(parsed.sha) : undefined;
|
||||
if (
|
||||
ref !== `refs/heads/${baseBranch}` ||
|
||||
!sha ||
|
||||
!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/iu.test(sha)
|
||||
) {
|
||||
throw new Error("GitHub publication workspace base branch could not be verified.");
|
||||
}
|
||||
return sha;
|
||||
}
|
||||
@@ -0,0 +1,592 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
BASE_HEAD,
|
||||
BRANCH,
|
||||
SESSION_ID,
|
||||
SESSION_KEY,
|
||||
WORKSPACE_TREE,
|
||||
commandResult,
|
||||
commands,
|
||||
createTestGitHubPublicationCoordinator,
|
||||
githubPublicationTestMocks,
|
||||
installGitHubPublicationTestHarness,
|
||||
root,
|
||||
seedLocalPublication,
|
||||
} from "./github-publication.test-support.js";
|
||||
import {
|
||||
REQUEST,
|
||||
seedActivePlacement,
|
||||
} from "./worker-environments/placement-dispatch-test-fixtures.js";
|
||||
import { createWorkerSessionPlacementStore } from "./worker-environments/placement-store.js";
|
||||
|
||||
const mocks = githubPublicationTestMocks();
|
||||
|
||||
describe("Gateway GitHub publication boundaries", () => {
|
||||
installGitHubPublicationTestHarness();
|
||||
|
||||
it.each([
|
||||
["URL rewrite", "url.https://attacker.invalid/.insteadof https://github.com/"],
|
||||
["HTTP proxy", "http.proxy https://attacker.invalid/"],
|
||||
["push expansion", "push.followtags true"],
|
||||
["worktree redirect", "core.worktree /tmp/other-checkout"],
|
||||
["alternate refs command", "core.alternaterefscommand ./steal-profile"],
|
||||
["askpass command", "core.askpass ./steal-profile"],
|
||||
["fsmonitor command", "core.fsmonitor ./steal-profile"],
|
||||
["credential helper", "credential.helper ./steal-profile"],
|
||||
["remote upload-pack", "remote.origin.uploadpack ./steal-profile"],
|
||||
["upload-pack hook", "uploadpack.packobjectshook ./steal-profile"],
|
||||
])("rejects repository-local %s before snapshot or transport", async (label, configLine) => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.includes("--includes") && argv.includes("--get-regexp")) {
|
||||
return commandResult(`${configLine}\n`);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: `unsafe-${label}`,
|
||||
}),
|
||||
).rejects.toThrow("unsupported Git transport configuration");
|
||||
expect(commands.some((argv) => argv.includes("push"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unsafe worktree-scoped transport config when the scope is enabled", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
const command = argv.join(" ");
|
||||
if (command === "git config --local --includes --bool --get extensions.worktreeConfig") {
|
||||
return commandResult("true\n");
|
||||
}
|
||||
if (argv.includes("--get-regexp")) {
|
||||
return argv.includes("--worktree")
|
||||
? commandResult("credential.helper ./steal-profile\n")
|
||||
: commandResult("", 1);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "unsafe-worktree-config",
|
||||
}),
|
||||
).rejects.toThrow("unsupported Git transport configuration");
|
||||
expect(commands.some((argv) => argv.includes("push"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the pull request base branch before any repository mutation", async () => {
|
||||
mocks.findWorktree.mockReturnValue({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: "main",
|
||||
baseRef: "origin/main",
|
||||
ownerKind: "session",
|
||||
ownerId: SESSION_KEY,
|
||||
});
|
||||
mocks.loadSession.mockReturnValue({
|
||||
canonicalKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
storePath: "/state/sessions.json",
|
||||
entry: {
|
||||
sessionId: SESSION_ID,
|
||||
worktree: { id: "worktree-1", branch: "main", repoRoot: "/repo" },
|
||||
},
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "base-branch",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "workspace_changed" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes a feature worktree whose base metadata is HEAD", async () => {
|
||||
mocks.findWorktree.mockImplementation((_ownerKind, ownerId: string) => ({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: BRANCH,
|
||||
baseRef: "HEAD",
|
||||
ownerKind: "session",
|
||||
ownerId,
|
||||
}));
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "head-base-metadata",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "published", branch: BRANCH });
|
||||
expect(commands.some((argv) => argv.join(" ").includes("git/ref/heads/main"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an accepted tree identical to the base before creating a marker commit", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.join(" ") === `git rev-parse ${BASE_HEAD}^{tree}`) {
|
||||
return commandResult(`${WORKSPACE_TREE}\n`);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "no-tree-change",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "no_changes" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when no local base commit can be verified", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv[0] === "git" && argv[1] === "reflog") {
|
||||
return commandResult();
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "missing-base",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "workspace_changed" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a local turn that starts and finishes during snapshot capture", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const placements = createWorkerSessionPlacementStore({ database });
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
let raced = false;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (!raced && argv.includes("add")) {
|
||||
raced = true;
|
||||
const claim = placements.claimTurn({
|
||||
sessionId: SESSION_ID,
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
claimId: "claim-during-snapshot",
|
||||
runId: "run-during-snapshot",
|
||||
owner: { kind: "local" },
|
||||
});
|
||||
placements.releaseTurn(claim);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({ placements });
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "local-turn-during-snapshot",
|
||||
}),
|
||||
).rejects.toThrow("session authority changed during snapshot");
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before mutation when the local base is outside the authenticated remote lineage", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv[0] === "git" && argv[1] === "merge-base") {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "unrelated-base-lineage",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "workspace_changed" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before mutation when the authenticated remote base cannot be materialized", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.includes("fetch")) {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "missing-remote-base-object",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "workspace_changed" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails before mutation when the target repository base branch is unavailable", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.join(" ").includes("/git/ref/heads/main")) {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "missing-remote-base",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "workspace_changed" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a matching pull request owned by another GitHub account", async () => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
const command = argv.join(" ");
|
||||
if (command.includes(" repos/openclaw/openclaw/pulls ")) {
|
||||
return commandResult(
|
||||
JSON.stringify([
|
||||
{
|
||||
url: "https://github.com/openclaw/openclaw/pull/foreign",
|
||||
userId: 99,
|
||||
state: "open",
|
||||
body: "",
|
||||
headSha: "b".repeat(40),
|
||||
headRef: BRANCH,
|
||||
baseRef: "main",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "foreign-pr",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "github_rejected" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "invalid JSON", response: "truncated" },
|
||||
{ label: "non-array JSON", response: "{}" },
|
||||
{ label: "invalid candidate", response: "[{}]" },
|
||||
])("fails closed for $label in pull request ownership", async ({ label, response }) => {
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.join(" ").includes(" repos/openclaw/openclaw/pulls ")) {
|
||||
return commandResult(response);
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: `invalid-pr-ownership-${label}`,
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "failed", code: "github_rejected" });
|
||||
expect(commands.some((argv) => argv.includes("commit-tree") || argv.includes("push"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an attributed marker commit when all changes were already committed", async () => {
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({
|
||||
database: openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } }),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.requestForSession({
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "committed-work",
|
||||
title: "Publish committed work",
|
||||
}),
|
||||
).resolves.toMatchObject({ status: "published", branch: BRANCH });
|
||||
expect(commands.filter((argv) => argv.includes("commit-tree"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps an incomplete Git transaction retryable until index recovery completes", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({ database }),
|
||||
});
|
||||
mocks.updateIndex.mockImplementationOnce(async () => {
|
||||
const { GitHubPublicationRecoveryPendingError } = await vi.importActual<
|
||||
typeof import("./github-publication-git-index.js")
|
||||
>("./github-publication-git-index.js");
|
||||
throw new GitHubPublicationRecoveryPendingError("workspace recovery is pending");
|
||||
});
|
||||
const request = {
|
||||
sessionKey: SESSION_KEY,
|
||||
agentId: "main",
|
||||
idempotencyKey: "recover-index-transaction",
|
||||
};
|
||||
|
||||
await expect(coordinator.requestForSession(request)).rejects.toThrow(
|
||||
"workspace recovery is pending",
|
||||
);
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT status FROM github_publication_requests WHERE idempotency_key = ?")
|
||||
.get(request.idempotencyKey),
|
||||
).toEqual({ status: "publishing" });
|
||||
await expect(coordinator.requestForSession(request)).resolves.toMatchObject({
|
||||
status: "published",
|
||||
});
|
||||
});
|
||||
|
||||
it("terminalizes local recovery when the managed worktree fingerprint changed", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const first = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({ database }),
|
||||
});
|
||||
first.read("create-schema");
|
||||
const requestId = "publication-stale-worktree";
|
||||
seedLocalPublication(database, {
|
||||
requestId,
|
||||
status: "requested",
|
||||
repositoryFingerprint: "replaced-fingerprint",
|
||||
});
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
const reopened = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const resumed = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({ database: reopened }),
|
||||
});
|
||||
|
||||
await resumed.resumeLocalRequests();
|
||||
|
||||
expect(resumed.read(requestId)).toEqual({
|
||||
requestId,
|
||||
status: "failed",
|
||||
code: "workspace_changed",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction:
|
||||
"Wait for the current turn to finish, inspect the reconciled workspace, and retry.",
|
||||
});
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates the live session owner before recovery can touch Git state", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({ database }),
|
||||
});
|
||||
coordinator.read("create-schema");
|
||||
const requestId = "publication-stale-session-owner";
|
||||
seedLocalPublication(database, { requestId, status: "requested" });
|
||||
mocks.findWorktreeById.mockReturnValue({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: BRANCH,
|
||||
baseRef: "origin/main",
|
||||
ownerKind: "session",
|
||||
ownerId: SESSION_KEY,
|
||||
});
|
||||
mocks.findWorktree.mockReturnValue({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: BRANCH,
|
||||
baseRef: "origin/main",
|
||||
ownerKind: "session",
|
||||
ownerId: "agent:main:dashboard:replacement",
|
||||
});
|
||||
|
||||
await coordinator.resumeLocalRequests();
|
||||
|
||||
expect(coordinator.read(requestId)).toMatchObject({
|
||||
status: "failed",
|
||||
code: "session_changed",
|
||||
});
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unsafe Git configuration before starting recovery probes", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const coordinator = createTestGitHubPublicationCoordinator({
|
||||
placements: createWorkerSessionPlacementStore({ database }),
|
||||
});
|
||||
coordinator.read("create-schema");
|
||||
const requestId = "publication-unsafe-recovery";
|
||||
seedLocalPublication(database, { requestId, status: "requested" });
|
||||
mocks.findWorktreeById.mockReturnValue({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: BRANCH,
|
||||
baseRef: "origin/main",
|
||||
ownerKind: "session",
|
||||
ownerId: SESSION_KEY,
|
||||
});
|
||||
const fallback = mocks.runCommand.getMockImplementation()!;
|
||||
mocks.runCommand.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
if (argv.includes("--local") && argv.includes("--get-regexp")) {
|
||||
return commandResult("core.fsmonitor ./untrusted-monitor\n");
|
||||
}
|
||||
return await fallback(argv, options);
|
||||
});
|
||||
|
||||
await coordinator.resumeLocalRequests();
|
||||
|
||||
expect(coordinator.read(requestId)).toMatchObject({
|
||||
status: "failed",
|
||||
code: "workspace_changed",
|
||||
});
|
||||
expect(commands.some((argv) => argv.join(" ") === "git rev-parse --git-path index")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("terminalizes an accepted request whose turn ended before workspace acceptance", async () => {
|
||||
const database = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: root } });
|
||||
const placements = createWorkerSessionPlacementStore({ database });
|
||||
const active = seedActivePlacement(placements, {
|
||||
environmentId: "environment-1",
|
||||
ownerEpoch: 2,
|
||||
});
|
||||
const claim = placements.claimTurn({
|
||||
sessionId: active.sessionId,
|
||||
sessionKey: active.sessionKey,
|
||||
agentId: active.agentId,
|
||||
claimId: "claim-orphan",
|
||||
runId: "run-orphan",
|
||||
owner: { kind: "worker", environmentId: "environment-1", ownerEpoch: 2 },
|
||||
});
|
||||
const coordinator = createTestGitHubPublicationCoordinator({ placements });
|
||||
const accepted = await coordinator.requestForClaim({
|
||||
claim,
|
||||
sessionKey: REQUEST.sessionKey,
|
||||
agentId: REQUEST.agentId,
|
||||
idempotencyKey: "publish-orphan",
|
||||
});
|
||||
placements.releaseTurn(claim);
|
||||
|
||||
const failed = coordinator.failOrphanedRequests();
|
||||
|
||||
expect(failed).toEqual([
|
||||
{
|
||||
sessionId: REQUEST.sessionId,
|
||||
sessionKey: REQUEST.sessionKey,
|
||||
agentId: REQUEST.agentId,
|
||||
result: {
|
||||
requestId: accepted.requestId,
|
||||
status: "failed",
|
||||
code: "session_changed",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction:
|
||||
"The originating turn ended before its workspace result was accepted. Start a new turn and request publication again.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(coordinator.listUnreportedResults()).toEqual(failed);
|
||||
expect(commands).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
SessionGitHubPublicationResult,
|
||||
SessionGitHubPublishParams,
|
||||
} from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import { executeSqliteQuerySync } from "../infra/kysely-sync.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
prepareCurrentGitHubPublicationIdentity,
|
||||
resolveGitHubPublicationWorktreeOwner,
|
||||
} from "./github-publication-availability.js";
|
||||
import {
|
||||
captureGitHubPublicationWorkspaceSnapshot,
|
||||
matchesGitHubPublicationIdentityRow,
|
||||
} from "./github-publication-executor.js";
|
||||
import {
|
||||
claimGitHubPublicationExecution as claimExecution,
|
||||
digestGitHubPublicationRequest as digestRequest,
|
||||
ensureGitHubPublicationStore as ensureSchema,
|
||||
githubPublicationDatabase as publicationDb,
|
||||
hasGitHubPublicationStore as schemaExists,
|
||||
projectGitHubPublicationResult as publicationResult,
|
||||
type GitHubPublicationRow as PublicationRow,
|
||||
} from "./github-publication-store.js";
|
||||
import { loadGatewaySessionEntryReadOnly } from "./session-utils.js";
|
||||
import type {
|
||||
WorkerSessionPlacementStore,
|
||||
WorkerSessionTurnClaim,
|
||||
} from "./worker-environments/placement-store.js";
|
||||
|
||||
type ClaimRequest = {
|
||||
claim: WorkerSessionTurnClaim;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
idempotencyKey: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
assertCurrent?: () => void;
|
||||
};
|
||||
|
||||
function exactClaimForPlacement(
|
||||
placement: NonNullable<ReturnType<WorkerSessionPlacementStore["get"]>>,
|
||||
): WorkerSessionTurnClaim | undefined {
|
||||
const claim = placement.turnClaim;
|
||||
if (!claim) {
|
||||
return undefined;
|
||||
}
|
||||
if (claim.owner === "worker") {
|
||||
if (
|
||||
(placement.state !== "active" && placement.state !== "draining") ||
|
||||
!placement.environmentId ||
|
||||
placement.activeOwnerEpoch !== claim.ownerEpoch
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
sessionId: placement.sessionId,
|
||||
claimId: claim.claimId,
|
||||
runId: claim.runId,
|
||||
placementGeneration: claim.generation,
|
||||
owner: {
|
||||
kind: "worker",
|
||||
environmentId: placement.environmentId,
|
||||
ownerEpoch: claim.ownerEpoch,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionId: placement.sessionId,
|
||||
claimId: claim.claimId,
|
||||
runId: claim.runId,
|
||||
placementGeneration: claim.generation,
|
||||
owner: {
|
||||
kind: "local",
|
||||
...(placement.environmentId ? { environmentId: placement.environmentId } : {}),
|
||||
...(placement.activeOwnerEpoch !== null ? { ownerEpoch: placement.activeOwnerEpoch } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGitHubPublicationCoordinatorMethods(params: {
|
||||
placements: WorkerSessionPlacementStore;
|
||||
instanceId: string;
|
||||
readById: (requestId: string) => PublicationRow | undefined;
|
||||
requestForClaim: (request: ClaimRequest) => Promise<SessionGitHubPublicationResult>;
|
||||
sameWorktree: (
|
||||
row: PublicationRow,
|
||||
worktree: ReturnType<typeof resolveGitHubPublicationWorktreeOwner>["worktree"],
|
||||
) => boolean;
|
||||
processRow: (
|
||||
initial: PublicationRow,
|
||||
validateAuthority: () => boolean,
|
||||
) => Promise<SessionGitHubPublicationResult>;
|
||||
failClaimPreparation: (
|
||||
claim: WorkerSessionTurnClaim,
|
||||
error: unknown,
|
||||
) => SessionGitHubPublicationResult[];
|
||||
complete: (row: PublicationRow, result: SessionGitHubPublicationResult) => PublicationRow;
|
||||
}) {
|
||||
const {
|
||||
readById,
|
||||
requestForClaim,
|
||||
sameWorktree,
|
||||
processRow,
|
||||
failClaimPreparation,
|
||||
instanceId,
|
||||
complete,
|
||||
} = params;
|
||||
return {
|
||||
async requestForSession(
|
||||
input: SessionGitHubPublishParams & {
|
||||
agentId: string;
|
||||
expectedRunId?: string;
|
||||
assertCurrent?: () => void;
|
||||
},
|
||||
): Promise<SessionGitHubPublicationResult> {
|
||||
ensureSchema();
|
||||
if (!input.sessionKey) {
|
||||
throw new Error("GitHub publication requires an authoritative session.");
|
||||
}
|
||||
input.assertCurrent?.();
|
||||
const initialLoaded = loadGatewaySessionEntryReadOnly(input.sessionKey, {
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const sessionId = initialLoaded.entry?.sessionId;
|
||||
if (!sessionId) {
|
||||
throw new Error("GitHub publication session changed.");
|
||||
}
|
||||
const initialAuthority = resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId,
|
||||
sessionKey: input.sessionKey,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const loaded = initialAuthority.loaded;
|
||||
const placement = params.placements.get(sessionId);
|
||||
const capturePlacement = placement
|
||||
? {
|
||||
state: placement.state,
|
||||
generation: placement.generation,
|
||||
updatedAtMs: placement.updatedAtMs,
|
||||
}
|
||||
: null;
|
||||
const assertCaptureAuthority = () => {
|
||||
input.assertCurrent?.();
|
||||
const current = params.placements.get(sessionId);
|
||||
const unchanged = capturePlacement
|
||||
? current?.state === capturePlacement.state &&
|
||||
current.generation === capturePlacement.generation &&
|
||||
current.updatedAtMs === capturePlacement.updatedAtMs &&
|
||||
!current.turnClaim
|
||||
: current === undefined;
|
||||
if (!unchanged) {
|
||||
throw new Error("GitHub publication session authority changed during snapshot.");
|
||||
}
|
||||
};
|
||||
const claim = placement ? exactClaimForPlacement(placement) : undefined;
|
||||
if (claim) {
|
||||
if (!input.expectedRunId) {
|
||||
throw new Error("GitHub publication cannot join another active session turn.");
|
||||
}
|
||||
if (claim.runId !== input.expectedRunId) {
|
||||
throw new Error("GitHub publication run identity changed.");
|
||||
}
|
||||
const accepted = await requestForClaim({
|
||||
claim,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
agentId: input.agentId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.body ? { body: input.body } : {}),
|
||||
...(input.assertCurrent ? { assertCurrent: input.assertCurrent } : {}),
|
||||
});
|
||||
input.assertCurrent?.();
|
||||
if (placement?.state !== "local") {
|
||||
return accepted;
|
||||
}
|
||||
const row = readById(accepted.requestId);
|
||||
if (!row) {
|
||||
throw new Error("GitHub publication request disappeared.");
|
||||
}
|
||||
return await processRow(row, () => {
|
||||
input.assertCurrent?.();
|
||||
return params.placements.validateTurnClaim(claim);
|
||||
});
|
||||
}
|
||||
if (placement && placement.state !== "local") {
|
||||
throw new Error(
|
||||
"GitHub publication for a cloud session must be requested by its next live turn.",
|
||||
);
|
||||
}
|
||||
const { worktree } = resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const requestDigest = digestRequest({
|
||||
sessionId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
});
|
||||
const database = openOpenClawStateDatabase().db;
|
||||
const existing = executeSqliteQuerySync(
|
||||
database,
|
||||
publicationDb(database)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", sessionId)
|
||||
.where("idempotency_key", "=", input.idempotencyKey),
|
||||
).rows[0];
|
||||
if (existing) {
|
||||
if (existing.request_digest !== requestDigest || !sameWorktree(existing, worktree)) {
|
||||
throw new Error("GitHub publication idempotency key was reused.");
|
||||
}
|
||||
if (existing.status === "published" || existing.status === "failed") {
|
||||
return publicationResult(existing);
|
||||
}
|
||||
}
|
||||
input.assertCurrent?.();
|
||||
const identity = await prepareCurrentGitHubPublicationIdentity(input.agentId);
|
||||
input.assertCurrent?.();
|
||||
const current = params.placements.get(sessionId);
|
||||
if ((current && current.state !== "local") || current?.turnClaim) {
|
||||
throw new Error("GitHub publication session authority changed after verification.");
|
||||
}
|
||||
const snapshot =
|
||||
existing?.source_head_commit && existing.source_index_tree && existing.workspace_tree
|
||||
? {
|
||||
sourceHeadCommit: existing.source_head_commit,
|
||||
sourceIndexTree: existing.source_index_tree,
|
||||
workspaceTree: existing.workspace_tree,
|
||||
}
|
||||
: await captureGitHubPublicationWorkspaceSnapshot({
|
||||
cwd: worktree.path,
|
||||
assertCurrent: assertCaptureAuthority,
|
||||
});
|
||||
assertCaptureAuthority();
|
||||
resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
agentId: input.agentId,
|
||||
expected: {
|
||||
worktreeId: worktree.id,
|
||||
repositoryFingerprint: worktree.repoFingerprint,
|
||||
branch: worktree.branch,
|
||||
},
|
||||
});
|
||||
const now = Date.now();
|
||||
const requestId = randomUUID();
|
||||
input.assertCurrent?.();
|
||||
const row = runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const query = publicationDb(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.insertInto("github_publication_requests")
|
||||
.values({
|
||||
request_id: requestId,
|
||||
idempotency_key: input.idempotencyKey,
|
||||
request_digest: requestDigest,
|
||||
session_id: sessionId,
|
||||
session_key: loaded.canonicalKey,
|
||||
agent_id: input.agentId,
|
||||
worktree_id: worktree.id,
|
||||
repository_fingerprint: worktree.repoFingerprint,
|
||||
claim_id: null,
|
||||
run_id: null,
|
||||
environment_id: null,
|
||||
owner_epoch: null,
|
||||
placement_generation: null,
|
||||
identity_source: identity.source,
|
||||
identity_profile_id: identity.profileId ?? null,
|
||||
identity_account_id: identity.account.accountId,
|
||||
identity_login: identity.account.login,
|
||||
title: input.title ?? null,
|
||||
body: input.body ?? null,
|
||||
status: "requested",
|
||||
gateway_instance_id: null,
|
||||
repository: null,
|
||||
branch: worktree.branch,
|
||||
base_branch: null,
|
||||
source_head_commit: snapshot.sourceHeadCommit,
|
||||
source_index_tree: snapshot.sourceIndexTree,
|
||||
workspace_tree: snapshot.workspaceTree,
|
||||
head_commit: null,
|
||||
pull_request_url: null,
|
||||
error_code: null,
|
||||
next_action: null,
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
reported_at_ms: null,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["session_id", "idempotency_key"]).doNothing(),
|
||||
),
|
||||
);
|
||||
const stored = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", sessionId)
|
||||
.where("idempotency_key", "=", input.idempotencyKey),
|
||||
).rows[0];
|
||||
if (
|
||||
!stored ||
|
||||
stored.request_digest !== requestDigest ||
|
||||
!matchesGitHubPublicationIdentityRow(stored, identity) ||
|
||||
!sameWorktree(stored, worktree)
|
||||
) {
|
||||
throw new Error("GitHub publication idempotency key was reused.");
|
||||
}
|
||||
return stored;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.request-idle" },
|
||||
);
|
||||
return await processRow(row, () => {
|
||||
input.assertCurrent?.();
|
||||
const latest = params.placements.get(sessionId);
|
||||
return (!latest || latest.state === "local") && !latest?.turnClaim;
|
||||
});
|
||||
},
|
||||
|
||||
async resumeLocalRequests(): Promise<void> {
|
||||
if (!schemaExists()) {
|
||||
return;
|
||||
}
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("claim_id", "is", null)
|
||||
.where("status", "in", ["requested", "publishing"])
|
||||
.orderBy("created_at_ms"),
|
||||
).rows;
|
||||
for (const row of rows) {
|
||||
await processRow(row, () => {
|
||||
const placement = params.placements.get(row.session_id);
|
||||
return (!placement || placement.state === "local") && !placement?.turnClaim;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async processClaim(claim: WorkerSessionTurnClaim): Promise<SessionGitHubPublicationResult[]> {
|
||||
ensureSchema();
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", claim.sessionId)
|
||||
.where("claim_id", "=", claim.claimId)
|
||||
.where("run_id", "=", claim.runId)
|
||||
.orderBy("created_at_ms"),
|
||||
).rows;
|
||||
if (
|
||||
rows.some((row) => !row.source_head_commit || !row.source_index_tree || !row.workspace_tree)
|
||||
) {
|
||||
return failClaimPreparation(
|
||||
claim,
|
||||
new Error("GitHub publication accepted workspace snapshot is missing."),
|
||||
);
|
||||
}
|
||||
const results: SessionGitHubPublicationResult[] = [];
|
||||
for (const row of rows) {
|
||||
results.push(
|
||||
await processRow(row, () => params.placements.validateWorkspaceResultClaim(claim)),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
|
||||
failOrphanedRequests(): Array<{
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
result: SessionGitHubPublicationResult;
|
||||
}> {
|
||||
if (!schemaExists()) {
|
||||
return [];
|
||||
}
|
||||
const pending = new Set(
|
||||
params.placements
|
||||
.listPendingWorkspaceResults()
|
||||
.map((row) => `${row.sessionId}\0${row.claimId}\0${row.runId}`),
|
||||
);
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("status", "in", ["requested", "publishing"])
|
||||
.orderBy("created_at_ms"),
|
||||
).rows;
|
||||
return rows.flatMap((row) => {
|
||||
// Claim-less rows are local publication requests. The startup/periodic
|
||||
// sweep resumes them against their persisted worktree authority.
|
||||
if (row.claim_id === null) {
|
||||
return [];
|
||||
}
|
||||
const ownerKey = `${row.session_id}\0${row.claim_id}\0${row.run_id}`;
|
||||
const placement = params.placements.get(row.session_id);
|
||||
const liveClaim = placement?.turnClaim;
|
||||
const stillLive =
|
||||
liveClaim?.claimId === row.claim_id &&
|
||||
liveClaim.runId === row.run_id &&
|
||||
liveClaim.generation === row.placement_generation;
|
||||
if (pending.has(ownerKey) || stillLive) {
|
||||
return [];
|
||||
}
|
||||
const claimed = claimExecution(row.request_id, instanceId);
|
||||
const terminal = publicationResult(
|
||||
complete(claimed, {
|
||||
requestId: row.request_id,
|
||||
status: "failed",
|
||||
code: "session_changed",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction:
|
||||
"The originating turn ended before its workspace result was accepted. Start a new turn and request publication again.",
|
||||
}),
|
||||
);
|
||||
return [
|
||||
{
|
||||
sessionId: row.session_id,
|
||||
sessionKey: row.session_key,
|
||||
agentId: row.agent_id,
|
||||
result: terminal,
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
|
||||
listUnreportedResults(): Array<{
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
result: SessionGitHubPublicationResult;
|
||||
}> {
|
||||
if (!schemaExists()) {
|
||||
return [];
|
||||
}
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("status", "in", ["published", "failed"])
|
||||
.where("reported_at_ms", "is", null)
|
||||
.orderBy("updated_at_ms"),
|
||||
).rows.map((row) => ({
|
||||
sessionId: row.session_id,
|
||||
sessionKey: row.session_key,
|
||||
agentId: row.agent_id,
|
||||
result: publicationResult(row),
|
||||
}));
|
||||
},
|
||||
|
||||
read(requestId: string): SessionGitHubPublicationResult | undefined {
|
||||
const row = readById(requestId);
|
||||
return row ? publicationResult(row) : undefined;
|
||||
},
|
||||
|
||||
markReported(requestId: string): void {
|
||||
ensureSchema();
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.updateTable("github_publication_requests")
|
||||
.set({ reported_at_ms: Date.now(), updated_at_ms: Date.now() })
|
||||
.where("request_id", "=", requestId)
|
||||
.where("reported_at_ms", "is", null),
|
||||
);
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.report" },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import { resolveGitCoauthorAttribution } from "../agents/git-coauthor-attribution.js";
|
||||
import type { PreparedGitHubPublicationIdentity } from "../agents/github-tool-identity.js";
|
||||
import { managedWorktrees } from "../agents/worktrees/service.js";
|
||||
import { runCommandBuffered } from "../process/exec.js";
|
||||
import type { DB as StateDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
currentGitHubPublicationConfig,
|
||||
matchesCurrentGitHubPublicationIdentity,
|
||||
prepareCurrentGitHubPublicationIdentity,
|
||||
resolveGitHubPublicationWorktreeOwner,
|
||||
} from "./github-publication-availability.js";
|
||||
import {
|
||||
githubPublicationBaseFetchArgs,
|
||||
githubPublicationBaseLineageArgs,
|
||||
githubPublicationBaseLookupArgs,
|
||||
githubPublicationBranchCreationArgs,
|
||||
parseGitHubPublicationBaseBranch,
|
||||
parseGitHubPublicationBaseRef,
|
||||
} from "./github-publication-base.js";
|
||||
import { resolveGitHubPublicationFailure } from "./github-publication-failure.js";
|
||||
import {
|
||||
GitHubPublicationRecoveryPendingError,
|
||||
assertGitHubPublicationRefCasCompleted,
|
||||
updateGitHubPublicationBranchAndIndex,
|
||||
} from "./github-publication-git-index.js";
|
||||
import {
|
||||
appendGitHubPublicationMessage,
|
||||
assertGitHubPublicationTreeHasNoFilters,
|
||||
assertSafeGitPublicationWorkspace,
|
||||
assertGitHubPublicationBranchRef,
|
||||
githubPublicationPushArgs,
|
||||
githubPublicationRemoteHeadArgs,
|
||||
githubPublicationUpdateRefArgs,
|
||||
} from "./github-publication-git-transport.js";
|
||||
import {
|
||||
githubPublicationCreatePullRequestArgs,
|
||||
githubPublicationPullRequestLookupArgs,
|
||||
parseGitHubPublicationPullRequests,
|
||||
resolveGitHubPublicationPullRequestUrl,
|
||||
} from "./github-publication-pull-requests.js";
|
||||
import { recoverGitHubPublicationWorkspace } from "./github-publication-recovery.js";
|
||||
import { parseGitHubRemoteUrl } from "./github-remote.js";
|
||||
import { resolveGitHubRepositoryTarget } from "./github-repository-target.js";
|
||||
import { SessionMutationAuthorizationChangedError } from "./session-sharing.js";
|
||||
|
||||
const PUBLICATION_MARKER = "OpenClaw-Publication";
|
||||
|
||||
type PublicationRow = StateDatabase["github_publication_requests"];
|
||||
|
||||
export function matchesGitHubPublicationIdentityRow(
|
||||
row: PublicationRow,
|
||||
identity: PreparedGitHubPublicationIdentity,
|
||||
): boolean {
|
||||
return (
|
||||
row.identity_source === identity.source &&
|
||||
row.identity_profile_id === (identity.profileId ?? null) &&
|
||||
row.identity_account_id === identity.account.accountId &&
|
||||
row.identity_login.toLowerCase() === identity.account.login.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
argv: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {},
|
||||
) {
|
||||
return await runCommandBuffered(argv, {
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
env: { ...(options.env ?? process.env), GIT_NO_REPLACE_OBJECTS: "1" },
|
||||
...(options.input !== undefined ? { input: options.input } : {}),
|
||||
timeoutMs: 60_000,
|
||||
maxOutputBytes: 256 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
async function requireCommand(
|
||||
argv: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv; input?: string } = {},
|
||||
): Promise<string> {
|
||||
const result = await runCommand(argv, options);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${argv[0]} command failed`);
|
||||
}
|
||||
return result.stdout.toString("utf8").trim();
|
||||
}
|
||||
|
||||
function parseJsonObject(value: string, label: string): Record<string, unknown> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} returned invalid JSON`, { cause: error });
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error(`${label} returned an invalid response`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export async function captureGitHubPublicationWorkspaceSnapshot(params: {
|
||||
cwd: string;
|
||||
assertCurrent?: () => void;
|
||||
}): Promise<{ sourceHeadCommit: string; sourceIndexTree: string; workspaceTree: string }> {
|
||||
const step = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
params.assertCurrent?.();
|
||||
const result = await operation();
|
||||
params.assertCurrent?.();
|
||||
return result;
|
||||
};
|
||||
await step(async () => await assertSafeGitPublicationWorkspace(params.cwd, runCommand));
|
||||
const sourceHeadCommit = await step(
|
||||
async () =>
|
||||
await requireCommand(["git", "rev-parse", "--verify", "HEAD^{commit}"], {
|
||||
cwd: params.cwd,
|
||||
}),
|
||||
);
|
||||
const sourceIndexTree = await step(
|
||||
async () =>
|
||||
await requireCommand(
|
||||
["git", "-c", `core.hooksPath=${os.devNull}`, "-c", "core.fsmonitor=false", "write-tree"],
|
||||
{ cwd: params.cwd },
|
||||
),
|
||||
);
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-github-snapshot-"));
|
||||
try {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
GIT_ATTR_NOSYSTEM: "1",
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_SYSTEM: os.devNull,
|
||||
GIT_INDEX_FILE: path.join(tempDir, "index"),
|
||||
};
|
||||
await step(async () => {
|
||||
await requireCommand(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"read-tree",
|
||||
sourceHeadCommit,
|
||||
],
|
||||
{ cwd: params.cwd, env },
|
||||
);
|
||||
});
|
||||
await step(async () => {
|
||||
await requireCommand(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
`core.attributesFile=${os.devNull}`,
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"add",
|
||||
"-A",
|
||||
],
|
||||
{ cwd: params.cwd, env },
|
||||
);
|
||||
});
|
||||
const workspaceTree = await step(
|
||||
async () =>
|
||||
await requireCommand(
|
||||
["git", "-c", `core.hooksPath=${os.devNull}`, "-c", "core.fsmonitor=false", "write-tree"],
|
||||
{ cwd: params.cwd, env },
|
||||
),
|
||||
);
|
||||
await step(
|
||||
async () =>
|
||||
await assertGitHubPublicationTreeHasNoFilters(params.cwd, workspaceTree, runCommand),
|
||||
);
|
||||
return { sourceHeadCommit, sourceIndexTree, workspaceTree };
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGitHubPublication(params: {
|
||||
initial: PublicationRow;
|
||||
validateAuthority: () => boolean;
|
||||
projectResult: (row: PublicationRow) => SessionGitHubPublicationResult;
|
||||
bindWorkspaceSnapshot: (input: {
|
||||
row: PublicationRow;
|
||||
sourceHeadCommit: string;
|
||||
sourceIndexTree: string;
|
||||
workspaceTree: string;
|
||||
}) => PublicationRow;
|
||||
updatePublishingFacts: (input: {
|
||||
row: PublicationRow;
|
||||
repository: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
sourceHeadCommit: string;
|
||||
workspaceTree: string;
|
||||
headCommit: string;
|
||||
}) => PublicationRow;
|
||||
complete: (row: PublicationRow, result: SessionGitHubPublicationResult) => PublicationRow;
|
||||
}): Promise<SessionGitHubPublicationResult> {
|
||||
const { initial } = params;
|
||||
if (initial.status === "published" || initial.status === "failed") {
|
||||
return params.projectResult(initial);
|
||||
}
|
||||
let activeIdentity: PreparedGitHubPublicationIdentity | undefined;
|
||||
const currentWorktree = () =>
|
||||
resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId: initial.session_id,
|
||||
sessionKey: initial.session_key,
|
||||
agentId: initial.agent_id,
|
||||
expected: {
|
||||
worktreeId: initial.worktree_id,
|
||||
repositoryFingerprint: initial.repository_fingerprint,
|
||||
branch: initial.branch,
|
||||
},
|
||||
});
|
||||
const assertAuthority = () => {
|
||||
if (!params.validateAuthority()) {
|
||||
throw new Error("GitHub publication session authority changed.");
|
||||
}
|
||||
currentWorktree();
|
||||
if (
|
||||
activeIdentity &&
|
||||
!matchesCurrentGitHubPublicationIdentity({
|
||||
agentId: initial.agent_id,
|
||||
identity: activeIdentity,
|
||||
})
|
||||
) {
|
||||
throw new Error("GitHub publication identity changed.");
|
||||
}
|
||||
};
|
||||
const step = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
assertAuthority();
|
||||
const value = await operation();
|
||||
assertAuthority();
|
||||
return value;
|
||||
};
|
||||
try {
|
||||
const { loaded, worktree } = currentWorktree();
|
||||
await step(async () => await assertSafeGitPublicationWorkspace(worktree.path, runCommand));
|
||||
await step(
|
||||
async () => await recoverGitHubPublicationWorkspace(initial, requireCommand, assertAuthority),
|
||||
);
|
||||
const repositoryIdentity = await step(
|
||||
async () => await managedWorktrees.resolveRepositoryIdentity(worktree.path),
|
||||
);
|
||||
if (
|
||||
repositoryIdentity.checkoutRoot !== worktree.path ||
|
||||
repositoryIdentity.repoRoot !== worktree.repoRoot ||
|
||||
repositoryIdentity.fingerprint !== worktree.repoFingerprint
|
||||
) {
|
||||
throw new Error("GitHub publication workspace repository changed.");
|
||||
}
|
||||
const remote = parseGitHubRemoteUrl(repositoryIdentity.originUrl);
|
||||
if (
|
||||
!remote ||
|
||||
!/^[A-Za-z0-9_.-]+$/u.test(remote.owner) ||
|
||||
!/^[A-Za-z0-9_.-]+$/u.test(remote.repo)
|
||||
) {
|
||||
throw new Error("GitHub publication requires a GitHub remote.");
|
||||
}
|
||||
const pushRepository = `${remote.owner}/${remote.repo}`;
|
||||
const branch = await step(
|
||||
async () =>
|
||||
await requireCommand(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
if (branch !== worktree.branch) {
|
||||
throw new Error("GitHub publication branch changed.");
|
||||
}
|
||||
let row = initial;
|
||||
let sourceHeadCommit = row.source_head_commit;
|
||||
let sourceIndexTree = row.source_index_tree;
|
||||
let workspaceTree = row.workspace_tree;
|
||||
if (!sourceHeadCommit || !sourceIndexTree || !workspaceTree) {
|
||||
const snapshot = await captureGitHubPublicationWorkspaceSnapshot({
|
||||
cwd: worktree.path,
|
||||
assertCurrent: assertAuthority,
|
||||
});
|
||||
row = params.bindWorkspaceSnapshot({ row, ...snapshot });
|
||||
sourceHeadCommit = snapshot.sourceHeadCommit;
|
||||
sourceIndexTree = snapshot.sourceIndexTree;
|
||||
workspaceTree = snapshot.workspaceTree;
|
||||
}
|
||||
let headCommit = await step(
|
||||
async () =>
|
||||
await requireCommand(["git", "rev-parse", "--verify", "HEAD^{commit}"], {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
const refreshIdentity = async (): Promise<PreparedGitHubPublicationIdentity> => {
|
||||
const identity = await step(
|
||||
async () => await prepareCurrentGitHubPublicationIdentity(initial.agent_id),
|
||||
);
|
||||
if (!matchesGitHubPublicationIdentityRow(initial, identity)) {
|
||||
throw new Error("GitHub publication identity changed.");
|
||||
}
|
||||
activeIdentity = identity;
|
||||
assertAuthority();
|
||||
return identity;
|
||||
};
|
||||
let identity = await refreshIdentity();
|
||||
const repositoryTarget = resolveGitHubRepositoryTarget(
|
||||
parseJsonObject(
|
||||
await step(
|
||||
async () =>
|
||||
await requireCommand(
|
||||
[
|
||||
"gh",
|
||||
"api",
|
||||
"--hostname",
|
||||
"github.com",
|
||||
`repos/${pushRepository}`,
|
||||
"--jq",
|
||||
"{fork, default_branch, parent: {name: .parent.name, default_branch: .parent.default_branch, owner: {login: .parent.owner.login}}}",
|
||||
],
|
||||
{ env: identity.env },
|
||||
),
|
||||
),
|
||||
"GitHub repository lookup",
|
||||
),
|
||||
{ owner: remote.owner, repo: remote.repo },
|
||||
);
|
||||
if (!repositoryTarget) {
|
||||
throw new Error("GitHub repository response omitted its publication target.");
|
||||
}
|
||||
const repository = `${repositoryTarget.pullRequest.owner}/${repositoryTarget.pullRequest.repo}`;
|
||||
const baseBranch = repositoryTarget.fork
|
||||
? repositoryTarget.pullRequest.defaultBranch
|
||||
: parseGitHubPublicationBaseBranch(
|
||||
worktree.baseRef,
|
||||
repositoryTarget.pullRequest.defaultBranch,
|
||||
);
|
||||
if (!repositoryTarget.fork && branch === baseBranch) {
|
||||
throw new Error("GitHub publication branch changed to its pull request base.");
|
||||
}
|
||||
const remoteBaseResult = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationBaseLookupArgs(repository, baseBranch), {
|
||||
env: identity.env,
|
||||
}),
|
||||
);
|
||||
if (remoteBaseResult.code !== 0) {
|
||||
throw new Error("GitHub publication workspace base branch could not be verified.");
|
||||
}
|
||||
const remoteBaseSha = parseGitHubPublicationBaseRef(
|
||||
remoteBaseResult.stdout.toString("utf8"),
|
||||
baseBranch,
|
||||
);
|
||||
await step(async () => await assertSafeGitPublicationWorkspace(worktree.path, runCommand));
|
||||
identity = await refreshIdentity();
|
||||
const baseTransportEnv = {
|
||||
...identity.env,
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_SYSTEM: os.devNull,
|
||||
};
|
||||
const baseFetched = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationBaseFetchArgs(repository, remoteBaseSha), {
|
||||
cwd: worktree.path,
|
||||
env: baseTransportEnv,
|
||||
}),
|
||||
);
|
||||
if (baseFetched.code !== 0) {
|
||||
throw new Error("GitHub publication workspace base could not be materialized.");
|
||||
}
|
||||
const creation = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationBranchCreationArgs(branch), {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
const creationEntries = creation.stdout.toString("utf8").trim().split(/\r?\n/u);
|
||||
const creationBase = creationEntries.at(-1) ?? "";
|
||||
if (creation.code !== 0 || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/iu.test(creationBase)) {
|
||||
throw new Error("GitHub publication workspace creation base could not be verified.");
|
||||
}
|
||||
const creationOwnsRemote = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationBaseLineageArgs(creationBase, remoteBaseSha), {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
const creationOwnsSource = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationBaseLineageArgs(creationBase, sourceHeadCommit), {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
if (creationOwnsRemote.code !== 0 || creationOwnsSource.code !== 0) {
|
||||
throw new Error("GitHub publication workspace base lineage could not be verified.");
|
||||
}
|
||||
const baseTree = await step(
|
||||
async () =>
|
||||
await requireCommand(["git", "rev-parse", `${remoteBaseSha}^{tree}`], {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
if (baseTree === workspaceTree) {
|
||||
throw new Error("GitHub publication has no changes to publish.");
|
||||
}
|
||||
const marker = `${PUBLICATION_MARKER}: ${row.request_id}`;
|
||||
const pullRequestMarker = `<!-- openclaw-publication:${row.request_id} -->`;
|
||||
const loadOpenPullRequests = async () => {
|
||||
const lookupIdentity = await refreshIdentity();
|
||||
const raw = await requireCommand(
|
||||
githubPublicationPullRequestLookupArgs({
|
||||
repository,
|
||||
owner: repositoryTarget.push.owner,
|
||||
branch,
|
||||
baseBranch,
|
||||
}),
|
||||
{ env: lookupIdentity.env },
|
||||
);
|
||||
const candidates = parseGitHubPublicationPullRequests(raw);
|
||||
return {
|
||||
accountId: lookupIdentity.account.accountId,
|
||||
candidates,
|
||||
};
|
||||
};
|
||||
const initialPullRequests = await step(loadOpenPullRequests);
|
||||
const occupiedPullRequest = initialPullRequests.candidates.find(
|
||||
(candidate) =>
|
||||
candidate.state === "open" &&
|
||||
candidate.headRef === branch &&
|
||||
candidate.baseRef === baseBranch,
|
||||
);
|
||||
if (occupiedPullRequest && occupiedPullRequest.userId !== initialPullRequests.accountId) {
|
||||
throw new Error("GitHub pull request is owned by another account.");
|
||||
}
|
||||
row = params.updatePublishingFacts({
|
||||
row,
|
||||
repository,
|
||||
branch,
|
||||
baseBranch,
|
||||
sourceHeadCommit,
|
||||
workspaceTree,
|
||||
headCommit,
|
||||
});
|
||||
|
||||
const currentMessage = await step(
|
||||
async () =>
|
||||
await requireCommand(["git", "show", "-s", "--format=%B", "HEAD"], {
|
||||
cwd: worktree.path,
|
||||
}),
|
||||
);
|
||||
const markerPresent = currentMessage.split(/\r?\n/u).includes(marker);
|
||||
const currentTree = await step(
|
||||
async () => await requireCommand(["git", "rev-parse", "HEAD^{tree}"], { cwd: worktree.path }),
|
||||
);
|
||||
const previousBranchHead = headCommit;
|
||||
let updateBranchRef: (() => Promise<void>) | undefined;
|
||||
if (markerPresent) {
|
||||
const markerParent = await step(
|
||||
async () => await requireCommand(["git", "rev-parse", "HEAD^"], { cwd: worktree.path }),
|
||||
);
|
||||
if (markerParent !== sourceHeadCommit || currentTree !== workspaceTree) {
|
||||
throw new Error("GitHub publication workspace changed after its accepted snapshot.");
|
||||
}
|
||||
} else {
|
||||
if (headCommit !== sourceHeadCommit) {
|
||||
throw new Error("GitHub publication workspace changed after its accepted snapshot.");
|
||||
}
|
||||
await step(async () => {
|
||||
await requireCommand(["git", "cat-file", "-e", `${workspaceTree}^{tree}`], {
|
||||
cwd: worktree.path,
|
||||
});
|
||||
});
|
||||
const attribution = resolveGitCoauthorAttribution({
|
||||
agentId: row.agent_id,
|
||||
config: currentGitHubPublicationConfig(),
|
||||
excludeAccountId: identity.account.accountId,
|
||||
sessionKey: row.session_key,
|
||||
storePath: loaded.storePath,
|
||||
});
|
||||
const title = row.title?.trim() || `Publish ${branch}`;
|
||||
const message = appendGitHubPublicationMessage(title, [
|
||||
...(attribution?.trailers ?? []),
|
||||
marker,
|
||||
]);
|
||||
const timestamp = new Date(row.created_at_ms).toISOString();
|
||||
identity = await refreshIdentity();
|
||||
const authorEnv = {
|
||||
...identity.env,
|
||||
GIT_AUTHOR_NAME: identity.account.login,
|
||||
GIT_COMMITTER_NAME: identity.account.login,
|
||||
GIT_AUTHOR_EMAIL: `${identity.account.accountId}+${identity.account.login}@users.noreply.github.com`,
|
||||
GIT_COMMITTER_EMAIL: `${identity.account.accountId}+${identity.account.login}@users.noreply.github.com`,
|
||||
GIT_AUTHOR_DATE: timestamp,
|
||||
GIT_COMMITTER_DATE: timestamp,
|
||||
};
|
||||
const commit = await step(
|
||||
async () =>
|
||||
await requireCommand(
|
||||
["git", "commit-tree", "--no-gpg-sign", workspaceTree, "-p", headCommit],
|
||||
{
|
||||
cwd: worktree.path,
|
||||
env: authorEnv,
|
||||
input: `${message}\n`,
|
||||
},
|
||||
),
|
||||
);
|
||||
await assertGitHubPublicationBranchRef(branch, async (argv) => {
|
||||
return (await step(async () => await runCommand(argv, { cwd: worktree.path }))).code ?? -1;
|
||||
});
|
||||
const previousHead = headCommit;
|
||||
updateBranchRef = async () => {
|
||||
const result = await runCommand(
|
||||
githubPublicationUpdateRefArgs(branch, commit, previousHead),
|
||||
{ cwd: worktree.path },
|
||||
);
|
||||
assertGitHubPublicationRefCasCompleted(result);
|
||||
};
|
||||
headCommit = commit;
|
||||
}
|
||||
await updateGitHubPublicationBranchAndIndex({
|
||||
cwd: worktree.path,
|
||||
requestId: row.request_id,
|
||||
branch,
|
||||
previousHead: previousBranchHead,
|
||||
sourceIndexTree,
|
||||
workspaceTree,
|
||||
headCommit,
|
||||
env: identity.env,
|
||||
assertCurrent: assertAuthority,
|
||||
run: async (argv, options) => await step(async () => await requireCommand(argv, options)),
|
||||
...(updateBranchRef ? { updateRef: updateBranchRef } : {}),
|
||||
});
|
||||
row = params.updatePublishingFacts({
|
||||
row,
|
||||
repository,
|
||||
branch,
|
||||
baseBranch,
|
||||
sourceHeadCommit,
|
||||
workspaceTree,
|
||||
headCommit,
|
||||
});
|
||||
|
||||
await step(async () => await assertSafeGitPublicationWorkspace(worktree.path, runCommand));
|
||||
const httpsRemote = `https://github.com/${pushRepository}.git`;
|
||||
identity = await refreshIdentity();
|
||||
let transportEnv = {
|
||||
...identity.env,
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_SYSTEM: os.devNull,
|
||||
};
|
||||
const pushArgs = [
|
||||
"git",
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
...githubPublicationPushArgs(httpsRemote, headCommit, branch).slice(1),
|
||||
];
|
||||
const observeRemoteHead = async () => {
|
||||
const observed = await requireCommand(githubPublicationRemoteHeadArgs(httpsRemote, branch), {
|
||||
cwd: worktree.path,
|
||||
env: transportEnv,
|
||||
});
|
||||
return observed.split(/\s+/u)[0] ?? "";
|
||||
};
|
||||
let remoteHead = await step(observeRemoteHead);
|
||||
if (remoteHead !== headCommit) {
|
||||
const pushed = await step(
|
||||
async () => await runCommand(pushArgs, { cwd: worktree.path, env: transportEnv }),
|
||||
);
|
||||
identity = await refreshIdentity();
|
||||
transportEnv = {
|
||||
...identity.env,
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_SYSTEM: os.devNull,
|
||||
};
|
||||
remoteHead = await step(observeRemoteHead);
|
||||
if (remoteHead !== headCommit) {
|
||||
throw new Error(
|
||||
pushed.code === 0 ? "GitHub push verification failed." : "GitHub push was rejected.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const findPullRequest = async (): Promise<string | undefined> => {
|
||||
const pullRequests = await loadOpenPullRequests();
|
||||
return resolveGitHubPublicationPullRequestUrl(pullRequests.candidates, {
|
||||
accountId: pullRequests.accountId,
|
||||
headCommit,
|
||||
branch,
|
||||
baseBranch,
|
||||
marker: pullRequestMarker,
|
||||
});
|
||||
};
|
||||
let pullRequestUrl = await step(findPullRequest);
|
||||
if (!pullRequestUrl) {
|
||||
const attribution = resolveGitCoauthorAttribution({
|
||||
agentId: row.agent_id,
|
||||
config: currentGitHubPublicationConfig(),
|
||||
excludeAccountId: identity.account.accountId,
|
||||
sessionKey: row.session_key,
|
||||
storePath: loaded.storePath,
|
||||
});
|
||||
const participantCredit = attribution?.logins.length
|
||||
? `\n\n## Participants\n\n${attribution.logins.map((login) => `- @${login}`).join("\n")}`
|
||||
: "";
|
||||
const body = `${row.body?.trim() || "Published by the Gateway after authoritative workspace reconciliation."}${participantCredit}\n\n<!-- openclaw-publication:${row.request_id} -->`;
|
||||
identity = await refreshIdentity();
|
||||
const created = await step(
|
||||
async () =>
|
||||
await runCommand(githubPublicationCreatePullRequestArgs(repository), {
|
||||
env: identity.env,
|
||||
input: JSON.stringify({
|
||||
title: row.title?.trim() || `Publish ${branch}`,
|
||||
body,
|
||||
head: `${repositoryTarget.push.owner}:${branch}`,
|
||||
base: baseBranch,
|
||||
draft: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (created.code === 0) {
|
||||
pullRequestUrl = readNonBlankString(
|
||||
parseJsonObject(created.stdout.toString("utf8"), "GitHub pull request creation").html_url,
|
||||
);
|
||||
}
|
||||
pullRequestUrl ??= await step(findPullRequest);
|
||||
}
|
||||
if (!pullRequestUrl) {
|
||||
throw new Error("GitHub pull request creation was rejected.");
|
||||
}
|
||||
return params.projectResult(
|
||||
params.complete(row, {
|
||||
requestId: row.request_id,
|
||||
status: "published",
|
||||
url: pullRequestUrl,
|
||||
repository,
|
||||
branch,
|
||||
headCommit,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubPublicationRecoveryPendingError) {
|
||||
throw error;
|
||||
}
|
||||
const failure = resolveGitHubPublicationFailure(error);
|
||||
const result = params.projectResult(
|
||||
params.complete(initial, {
|
||||
requestId: initial.request_id,
|
||||
status: "failed",
|
||||
code: failure.code,
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: failure.nextAction,
|
||||
}),
|
||||
);
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
|
||||
export function resolveGitHubPublicationFailure(error: unknown): {
|
||||
code: Extract<SessionGitHubPublicationResult, { status: "failed" }>["code"];
|
||||
nextAction: string;
|
||||
} {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (message.includes("identity")) {
|
||||
return {
|
||||
code: message.includes("changed") ? "identity_changed" : "identity_unavailable",
|
||||
nextAction:
|
||||
"Reconnect the GitHub identity in Agents → Tools, then request publication again.",
|
||||
};
|
||||
}
|
||||
if (message.includes("session") || message.includes("worktree owner")) {
|
||||
return {
|
||||
code: "session_changed",
|
||||
nextAction: "Open the current session worktree and request publication again.",
|
||||
};
|
||||
}
|
||||
if (message.includes("workspace") || message.includes("branch changed")) {
|
||||
return {
|
||||
code: "workspace_changed",
|
||||
nextAction:
|
||||
"Wait for the current turn to finish, inspect the reconciled workspace, and retry.",
|
||||
};
|
||||
}
|
||||
if (message.includes("not a git")) {
|
||||
return { code: "not_git", nextAction: "Use a session-owned Git worktree to publish." };
|
||||
}
|
||||
if (message.includes("GitHub remote")) {
|
||||
return { code: "not_github", nextAction: "Use a GitHub repository remote to publish." };
|
||||
}
|
||||
if (message.includes("no changes")) {
|
||||
return { code: "no_changes", nextAction: "Make or restore a repository change, then retry." };
|
||||
}
|
||||
if (message.includes("push")) {
|
||||
return {
|
||||
code: "push_rejected",
|
||||
nextAction:
|
||||
"Check repository write access and branch drift, then retry without force-pushing.",
|
||||
};
|
||||
}
|
||||
if (message.includes("pull request was closed")) {
|
||||
return {
|
||||
code: "github_rejected",
|
||||
nextAction: "Reopen the closed pull request or retry to create a new publication request.",
|
||||
};
|
||||
}
|
||||
if (message.includes("pull request") || message.includes("GitHub")) {
|
||||
return {
|
||||
code: "github_rejected",
|
||||
nextAction: "Check pull-request permission for the effective account, then retry.",
|
||||
};
|
||||
}
|
||||
return { code: "unavailable", nextAction: "Retry after the Gateway and GitHub are available." };
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { runCommandBuffered } from "../process/exec.js";
|
||||
import {
|
||||
createOpenClawTestState,
|
||||
type OpenClawTestState,
|
||||
} from "../test-utils/openclaw-test-state.js";
|
||||
import {
|
||||
assertGitHubPublicationRefCasCompleted,
|
||||
recoverGitHubPublicationBranchAndIndex,
|
||||
updateGitHubPublicationBranchAndIndex,
|
||||
} from "./github-publication-git-index.js";
|
||||
import {
|
||||
assertGitHubPublicationTreeHasNoFilters,
|
||||
assertSafeGitPublicationWorkspace,
|
||||
} from "./github-publication-git-transport.js";
|
||||
|
||||
let testState: OpenClawTestState;
|
||||
let directoryIndex = 0;
|
||||
const REQUEST_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
beforeEach(async () => {
|
||||
testState = await createOpenClawTestState({ prefix: "openclaw-publication-index-" });
|
||||
directoryIndex = 0;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testState.cleanup();
|
||||
});
|
||||
|
||||
async function makeDirectory(label: string): Promise<string> {
|
||||
const directory = testState.path(`${label}-${directoryIndex++}`);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
return directory;
|
||||
}
|
||||
|
||||
async function git(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
input?: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<string> {
|
||||
const result = await runCommandBuffered(["git", ...args], {
|
||||
cwd,
|
||||
env,
|
||||
...(input === undefined ? {} : { input }),
|
||||
timeoutMs: 10_000,
|
||||
maxOutputBytes: 64 * 1024,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.stderr.toString("utf8") || `git ${args[0]} failed`);
|
||||
}
|
||||
return result.stdout.toString("utf8").trim();
|
||||
}
|
||||
|
||||
async function createFixture() {
|
||||
const cwd = await makeDirectory("git");
|
||||
await git(cwd, ["init", "--initial-branch=main"]);
|
||||
await git(cwd, ["config", "user.name", "OpenClaw Test"]);
|
||||
await git(cwd, ["config", "user.email", "openclaw@example.test"]);
|
||||
await fs.writeFile(path.join(cwd, "artifact.txt"), "base\n");
|
||||
await git(cwd, ["add", "artifact.txt"]);
|
||||
await git(cwd, ["commit", "-m", "base"]);
|
||||
const previousHead = await git(cwd, ["rev-parse", "HEAD"]);
|
||||
await fs.writeFile(path.join(cwd, "artifact.txt"), "accepted\n");
|
||||
await git(cwd, ["add", "artifact.txt"]);
|
||||
const sourceIndexTree = await git(cwd, ["write-tree"]);
|
||||
const headCommit = await git(
|
||||
cwd,
|
||||
["commit-tree", sourceIndexTree, "-p", previousHead],
|
||||
`published\n\nOpenClaw-Publication: ${REQUEST_ID}\n`,
|
||||
);
|
||||
return { cwd, previousHead, sourceIndexTree, workspaceTree: sourceIndexTree, headCommit };
|
||||
}
|
||||
|
||||
function publicationIndexParams(fixture: Awaited<ReturnType<typeof createFixture>>) {
|
||||
return {
|
||||
...fixture,
|
||||
requestId: REQUEST_ID,
|
||||
branch: "main",
|
||||
env: process.env,
|
||||
assertCurrent: () => undefined,
|
||||
run: async (
|
||||
argv: string[],
|
||||
options?: { cwd?: string; input?: string; env?: NodeJS.ProcessEnv },
|
||||
) => await git(fixture.cwd, argv.slice(1), options?.input, options?.env),
|
||||
};
|
||||
}
|
||||
|
||||
describe("GitHub publication index update", () => {
|
||||
it("accepts a linked worktree without a worktree config scope", async () => {
|
||||
const repository = await makeDirectory("worktree-config");
|
||||
await git(repository, ["init", "--initial-branch=main"]);
|
||||
await git(repository, ["config", "user.name", "OpenClaw Test"]);
|
||||
await git(repository, ["config", "user.email", "openclaw@example.test"]);
|
||||
await fs.writeFile(path.join(repository, "artifact.txt"), "base\n");
|
||||
await git(repository, ["add", "artifact.txt"]);
|
||||
await git(repository, ["commit", "-m", "base"]);
|
||||
const linked = testState.path(`linked-${directoryIndex++}`);
|
||||
await git(repository, ["worktree", "add", "-b", "publication", linked]);
|
||||
|
||||
await expect(
|
||||
assertSafeGitPublicationWorkspace(
|
||||
linked,
|
||||
async (argv, options) =>
|
||||
await runCommandBuffered(argv, {
|
||||
cwd: options?.cwd ?? linked,
|
||||
env: options?.env,
|
||||
timeoutMs: 10_000,
|
||||
maxOutputBytes: 64 * 1024,
|
||||
}),
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a filter from Git's default global attributes file", async () => {
|
||||
const cwd = await makeDirectory("attributes");
|
||||
const globalAttributes = path.join(cwd, "global-attributes");
|
||||
await fs.writeFile(globalAttributes, "*.secret filter=redact\n");
|
||||
|
||||
await expect(
|
||||
assertGitHubPublicationTreeHasNoFilters(cwd, "a".repeat(40), async (argv) => {
|
||||
const command = argv.join(" ");
|
||||
if (command === "git var GIT_ATTR_GLOBAL") {
|
||||
return { code: 0, stdout: Buffer.from(globalAttributes) };
|
||||
}
|
||||
if (command === "git var GIT_ATTR_SYSTEM") {
|
||||
return { code: 0, stdout: Buffer.from(path.join(cwd, "missing-system-attributes")) };
|
||||
}
|
||||
if (argv.includes("ls-tree")) {
|
||||
return { code: 0, stdout: Buffer.alloc(0) };
|
||||
}
|
||||
if (command === "git rev-parse --git-path info/attributes") {
|
||||
return { code: 0, stdout: Buffer.from(path.join(cwd, "missing-info-attributes")) };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
}),
|
||||
).rejects.toThrow("unsupported Git clean filter");
|
||||
});
|
||||
|
||||
it("moves the branch and index together without changing accepted worktree content", async () => {
|
||||
const fixture = await createFixture();
|
||||
await updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
await git(fixture.cwd, [
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
fixture.headCommit,
|
||||
fixture.previousHead,
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.headCommit);
|
||||
expect(await git(fixture.cwd, ["write-tree"])).toBe(fixture.workspaceTree);
|
||||
expect(await git(fixture.cwd, ["status", "--porcelain"])).toBe("");
|
||||
expect(await fs.readFile(path.join(fixture.cwd, "artifact.txt"), "utf8")).toBe("accepted\n");
|
||||
});
|
||||
|
||||
it("rejects concurrent staged changes without moving HEAD or rewriting the index", async () => {
|
||||
const fixture = await createFixture();
|
||||
await fs.writeFile(path.join(fixture.cwd, "concurrent.txt"), "keep staged\n");
|
||||
await git(fixture.cwd, ["add", "concurrent.txt"]);
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
throw new Error("update-ref must not run");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("workspace index changed");
|
||||
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.previousHead);
|
||||
expect(await git(fixture.cwd, ["diff", "--cached", "--name-only"])).toContain("concurrent.txt");
|
||||
});
|
||||
|
||||
it("retries a complete recovery lock when an ambiguous ref CAS did not move", async () => {
|
||||
const fixture = await createFixture();
|
||||
const indexPath = path.join(fixture.cwd, ".git", "index");
|
||||
const indexBefore = await fs.readFile(indexPath);
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
throw new Error("ref changed");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("workspace recovery is pending");
|
||||
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.previousHead);
|
||||
expect(await fs.readFile(indexPath)).toEqual(indexBefore);
|
||||
const recoveryLock = await fs.stat(path.join(fixture.cwd, ".git", "index.lock"));
|
||||
expect(recoveryLock.size).toBeGreaterThan(0);
|
||||
|
||||
await updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
await git(fixture.cwd, [
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
fixture.headCommit,
|
||||
fixture.previousHead,
|
||||
]);
|
||||
},
|
||||
});
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.headCommit);
|
||||
expect(await git(fixture.cwd, ["status", "--porcelain"])).toBe("");
|
||||
});
|
||||
|
||||
it("installs a retained recovery index when an ambiguous ref CAS moved", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
await git(fixture.cwd, [
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
fixture.headCommit,
|
||||
fixture.previousHead,
|
||||
]);
|
||||
throw new Error("response lost");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("workspace recovery is pending");
|
||||
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.headCommit);
|
||||
await recoverGitHubPublicationBranchAndIndex({
|
||||
cwd: fixture.cwd,
|
||||
requestId: REQUEST_ID,
|
||||
branch: "main",
|
||||
sourceHeadCommit: fixture.previousHead,
|
||||
workspaceTree: fixture.workspaceTree,
|
||||
assertCurrent: () => undefined,
|
||||
run: async (argv, options) =>
|
||||
await git(fixture.cwd, argv.slice(1), options?.input, options?.env),
|
||||
});
|
||||
expect(await git(fixture.cwd, ["write-tree"])).toBe(fixture.workspaceTree);
|
||||
expect(await git(fixture.cwd, ["status", "--porcelain"])).toBe("");
|
||||
await expect(fs.stat(path.join(fixture.cwd, ".git", "index.lock"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("does not install a recovered index after authority changes during Git probes", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
await git(fixture.cwd, [
|
||||
"update-ref",
|
||||
"refs/heads/main",
|
||||
fixture.headCommit,
|
||||
fixture.previousHead,
|
||||
]);
|
||||
throw new Error("response lost");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("workspace recovery is pending");
|
||||
|
||||
let current = true;
|
||||
await expect(
|
||||
recoverGitHubPublicationBranchAndIndex({
|
||||
cwd: fixture.cwd,
|
||||
requestId: REQUEST_ID,
|
||||
branch: "main",
|
||||
sourceHeadCommit: fixture.previousHead,
|
||||
workspaceTree: fixture.workspaceTree,
|
||||
assertCurrent: () => {
|
||||
if (!current) {
|
||||
throw new Error("publication authority changed");
|
||||
}
|
||||
},
|
||||
run: async (argv, options) => {
|
||||
const result = await git(fixture.cwd, argv.slice(1), options?.input, options?.env);
|
||||
if (argv[1] === "show") {
|
||||
current = false;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("publication authority changed");
|
||||
await expect(fs.stat(path.join(fixture.cwd, ".git", "index.lock"))).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("does not claim another Git operation's byte-identical index lock", async () => {
|
||||
const fixture = await createFixture();
|
||||
const foreignIndex = path.join(fixture.cwd, "foreign-index");
|
||||
await git(fixture.cwd, ["read-tree", fixture.headCommit], undefined, {
|
||||
...process.env,
|
||||
GIT_INDEX_FILE: foreignIndex,
|
||||
});
|
||||
await fs.rename(foreignIndex, path.join(fixture.cwd, ".git", "index.lock"));
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
throw new Error("update-ref must not run");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("locked by another operation");
|
||||
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.previousHead);
|
||||
expect((await fs.stat(path.join(fixture.cwd, ".git", "index.lock"))).size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("removes its owned lock after a definite ref CAS rejection", async () => {
|
||||
const fixture = await createFixture();
|
||||
|
||||
await expect(
|
||||
updateGitHubPublicationBranchAndIndex({
|
||||
...publicationIndexParams(fixture),
|
||||
updateRef: async () => {
|
||||
const result = await runCommandBuffered(
|
||||
["git", "update-ref", "refs/heads/main", fixture.headCommit, "f".repeat(40)],
|
||||
{ cwd: fixture.cwd },
|
||||
);
|
||||
assertGitHubPublicationRefCasCompleted(result);
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("workspace branch changed before commit");
|
||||
|
||||
await expect(fs.stat(path.join(fixture.cwd, ".git", "index.lock"))).rejects.toThrow();
|
||||
expect(await git(fixture.cwd, ["rev-parse", "HEAD"])).toBe(fixture.previousHead);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type GitCommandOptions = { cwd?: string; env?: NodeJS.ProcessEnv; input?: string };
|
||||
|
||||
class GitHubPublicationRefCasRejectedError extends Error {}
|
||||
export class GitHubPublicationRecoveryPendingError extends Error {}
|
||||
|
||||
export function assertGitHubPublicationRefCasCompleted(result: {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
killed: boolean;
|
||||
}): void {
|
||||
if (result.code === 0) {
|
||||
return;
|
||||
}
|
||||
if (result.signal === null && !result.killed) {
|
||||
throw new GitHubPublicationRefCasRejectedError(
|
||||
"GitHub publication workspace branch changed before commit.",
|
||||
);
|
||||
}
|
||||
throw new Error("GitHub publication workspace branch update outcome is unknown.");
|
||||
}
|
||||
|
||||
async function syncDirectory(directory: string): Promise<void> {
|
||||
let handle: FileHandle | undefined;
|
||||
try {
|
||||
handle = await fs.open(directory, "r");
|
||||
await handle.sync();
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
||||
if (process.platform !== "win32" || (code !== "EINVAL" && code !== "EPERM")) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): unknown {
|
||||
return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
||||
}
|
||||
|
||||
async function sameFile(left: string, right: string): Promise<boolean> {
|
||||
try {
|
||||
const [leftStat, rightStat] = await Promise.all([fs.stat(left), fs.stat(right)]);
|
||||
return (
|
||||
leftStat.nlink >= 2 &&
|
||||
rightStat.nlink >= 2 &&
|
||||
leftStat.dev === rightStat.dev &&
|
||||
leftStat.ino === rightStat.ino
|
||||
);
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDurableFile(file: string, contents: Buffer): Promise<void> {
|
||||
await fs.writeFile(file, contents, { flag: "w", mode: 0o600 });
|
||||
const handle = await fs.open(file, "r+");
|
||||
try {
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function publicationRecoveryPath(indexPath: string, requestId: string): string {
|
||||
const recoveryId = createHash("sha256").update(requestId).digest("hex");
|
||||
return `${indexPath}.openclaw-${recoveryId}`;
|
||||
}
|
||||
|
||||
export async function recoverGitHubPublicationBranchAndIndex(params: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
branch: string;
|
||||
sourceHeadCommit: string;
|
||||
workspaceTree: string;
|
||||
assertCurrent: () => void;
|
||||
run: (argv: string[], options?: GitCommandOptions) => Promise<string>;
|
||||
}): Promise<void> {
|
||||
const mutate = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
params.assertCurrent();
|
||||
return await operation();
|
||||
};
|
||||
const rawIndexPath = await params.run(["git", "rev-parse", "--git-path", "index"], {
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const indexPath = path.resolve(params.cwd, rawIndexPath);
|
||||
const lockPath = `${indexPath}.lock`;
|
||||
const recoveryPath = publicationRecoveryPath(indexPath, params.requestId);
|
||||
if (!(await pathExists(recoveryPath))) {
|
||||
return;
|
||||
}
|
||||
if (!(await sameFile(recoveryPath, lockPath))) {
|
||||
if (await pathExists(lockPath)) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace recovery is waiting for another Git operation.",
|
||||
);
|
||||
}
|
||||
const branchHead = await params.run(
|
||||
["git", "rev-parse", "--verify", `refs/heads/${params.branch}`],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
const indexTree = await params.run(["git", "write-tree"], { cwd: params.cwd });
|
||||
if (
|
||||
branchHead === params.sourceHeadCommit ||
|
||||
(indexTree === params.workspaceTree && (await publicationCommitMatches(params, branchHead)))
|
||||
) {
|
||||
await mutate(async () => await fs.rm(recoveryPath, { force: true }));
|
||||
return;
|
||||
}
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace recovery is pending.",
|
||||
);
|
||||
}
|
||||
const branchHead = await params.run(
|
||||
["git", "rev-parse", "--verify", `refs/heads/${params.branch}`],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
if (branchHead === params.sourceHeadCommit) {
|
||||
await mutate(async () => await fs.rm(lockPath, { force: true }));
|
||||
await mutate(async () => await fs.rm(recoveryPath, { force: true }));
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
return;
|
||||
}
|
||||
if (!(await publicationCommitMatches(params, branchHead))) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace branch recovery is pending.",
|
||||
);
|
||||
}
|
||||
await mutate(async () => await fs.rename(lockPath, indexPath));
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
await mutate(async () => await fs.rm(recoveryPath, { force: true }));
|
||||
}
|
||||
|
||||
async function publicationCommitMatches(
|
||||
params: Pick<
|
||||
Parameters<typeof recoverGitHubPublicationBranchAndIndex>[0],
|
||||
"cwd" | "requestId" | "sourceHeadCommit" | "workspaceTree" | "run"
|
||||
>,
|
||||
headCommit: string,
|
||||
): Promise<boolean> {
|
||||
const [message, parent, tree] = await Promise.all([
|
||||
params.run(["git", "show", "-s", "--format=%B", headCommit], { cwd: params.cwd }),
|
||||
params.run(["git", "rev-parse", `${headCommit}^`], { cwd: params.cwd }),
|
||||
params.run(["git", "rev-parse", `${headCommit}^{tree}`], { cwd: params.cwd }),
|
||||
]);
|
||||
return (
|
||||
message.split(/\r?\n/u).includes(`OpenClaw-Publication: ${params.requestId}`) &&
|
||||
parent === params.sourceHeadCommit &&
|
||||
tree === params.workspaceTree
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves the branch and accepted index together while honoring Git's standard index lock. */
|
||||
export async function updateGitHubPublicationBranchAndIndex(params: {
|
||||
cwd: string;
|
||||
requestId: string;
|
||||
branch: string;
|
||||
previousHead: string;
|
||||
sourceIndexTree: string;
|
||||
workspaceTree: string;
|
||||
headCommit: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
assertCurrent: () => void;
|
||||
run: (argv: string[], options?: GitCommandOptions) => Promise<string>;
|
||||
updateRef?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-github-index-"));
|
||||
const replacementIndex = path.join(tempDir, "replacement-index");
|
||||
const observedIndex = path.join(tempDir, "observed-index");
|
||||
let lockPath: string | undefined;
|
||||
let recoveryPath: string | undefined;
|
||||
let ownsLock = false;
|
||||
let refMayHaveMoved = false;
|
||||
let installed = false;
|
||||
try {
|
||||
const rawIndexPath = await params.run(["git", "rev-parse", "--git-path", "index"], {
|
||||
cwd: params.cwd,
|
||||
});
|
||||
const indexPath = path.resolve(params.cwd, rawIndexPath);
|
||||
lockPath = `${indexPath}.lock`;
|
||||
recoveryPath = publicationRecoveryPath(indexPath, params.requestId);
|
||||
const gitEnv = {
|
||||
...params.env,
|
||||
GIT_CONFIG_GLOBAL: os.devNull,
|
||||
GIT_CONFIG_SYSTEM: os.devNull,
|
||||
};
|
||||
const hardenedGit = ["git", "-c", `core.hooksPath=${os.devNull}`, "-c", "core.fsmonitor=false"];
|
||||
await params.run([...hardenedGit, "read-tree", params.headCommit], {
|
||||
cwd: params.cwd,
|
||||
env: { ...gitEnv, GIT_INDEX_FILE: replacementIndex },
|
||||
});
|
||||
const replacement = await fs.readFile(replacementIndex);
|
||||
let recoveryIndex: Buffer | undefined;
|
||||
try {
|
||||
recoveryIndex = await fs.readFile(recoveryPath);
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (recoveryIndex && !recoveryIndex.equals(replacement)) {
|
||||
const branchHead = await params.run(
|
||||
["git", "rev-parse", "--verify", `refs/heads/${params.branch}`],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
if ((await sameFile(recoveryPath, lockPath)) || branchHead !== params.previousHead) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace recovery data changed.",
|
||||
);
|
||||
}
|
||||
recoveryIndex = undefined;
|
||||
}
|
||||
if (!recoveryIndex) {
|
||||
await writeDurableFile(recoveryPath, replacement);
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
}
|
||||
if (await sameFile(recoveryPath, lockPath)) {
|
||||
const branchHead = await params.run(
|
||||
["git", "rev-parse", "--verify", `refs/heads/${params.branch}`],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
if (branchHead === params.headCommit) {
|
||||
try {
|
||||
await fs.rename(lockPath, indexPath);
|
||||
installed = true;
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
await fs.rm(recoveryPath, { force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace index recovery is pending.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (branchHead !== params.previousHead) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace branch recovery is pending.",
|
||||
);
|
||||
}
|
||||
await fs.rm(lockPath);
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
} else if (await pathExists(lockPath)) {
|
||||
throw new Error("GitHub publication workspace index is locked by another operation.");
|
||||
}
|
||||
params.assertCurrent();
|
||||
try {
|
||||
await fs.link(recoveryPath, lockPath);
|
||||
ownsLock = true;
|
||||
} catch (error) {
|
||||
throw new Error("GitHub publication workspace index changed before commit.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
await fs.copyFile(indexPath, observedIndex);
|
||||
const currentIndexTree = await params.run([...hardenedGit, "write-tree"], {
|
||||
cwd: params.cwd,
|
||||
env: { ...gitEnv, GIT_INDEX_FILE: observedIndex },
|
||||
});
|
||||
if (currentIndexTree !== params.sourceIndexTree && currentIndexTree !== params.workspaceTree) {
|
||||
throw new Error("GitHub publication workspace index changed after its accepted snapshot.");
|
||||
}
|
||||
params.assertCurrent();
|
||||
// The request-owned recovery inode proves whether a retained standard Git
|
||||
// lock belongs to this transaction; matching bytes alone never claim it.
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
params.assertCurrent();
|
||||
if (params.updateRef) {
|
||||
refMayHaveMoved = true;
|
||||
try {
|
||||
await params.updateRef();
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubPublicationRefCasRejectedError) {
|
||||
refMayHaveMoved = false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
params.assertCurrent();
|
||||
await fs.rename(lockPath, indexPath);
|
||||
ownsLock = false;
|
||||
installed = true;
|
||||
await syncDirectory(path.dirname(indexPath));
|
||||
await fs.rm(recoveryPath, { force: true });
|
||||
} catch (error) {
|
||||
if (!installed && refMayHaveMoved && ownsLock) {
|
||||
throw new GitHubPublicationRecoveryPendingError(
|
||||
"GitHub publication workspace recovery is pending.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (!installed && !refMayHaveMoved && ownsLock && lockPath) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
}
|
||||
if ((installed || !refMayHaveMoved) && recoveryPath) {
|
||||
await fs.rm(recoveryPath, { force: true });
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { githubPublicationUnsafeConfigArgs } from "./github-publication-base.js";
|
||||
|
||||
type GitCommandOptions = { cwd?: string; env?: NodeJS.ProcessEnv; input?: string };
|
||||
type GitCommandResult = { code: number | null; stdout: Buffer };
|
||||
|
||||
export async function assertSafeGitPublicationWorkspace(
|
||||
cwd: string,
|
||||
run: (argv: string[], options?: GitCommandOptions) => Promise<GitCommandResult>,
|
||||
): Promise<void> {
|
||||
const isolatedConfig = { GIT_CONFIG_GLOBAL: os.devNull, GIT_CONFIG_SYSTEM: os.devNull };
|
||||
const [localUnsafe, worktreeConfig] = await Promise.all([
|
||||
run(githubPublicationUnsafeConfigArgs("--local"), { cwd, env: isolatedConfig }),
|
||||
run(
|
||||
["git", "config", "--local", "--includes", "--bool", "--get", "extensions.worktreeConfig"],
|
||||
{ cwd, env: isolatedConfig },
|
||||
),
|
||||
]);
|
||||
const worktreeConfigValue = worktreeConfig.stdout.toString("utf8").trim();
|
||||
const worktreeConfigKnown =
|
||||
(worktreeConfig.code === 0 &&
|
||||
(worktreeConfigValue === "true" || worktreeConfigValue === "false")) ||
|
||||
(worktreeConfig.code === 1 && worktreeConfig.stdout.length === 0);
|
||||
if (localUnsafe.code !== 1 || localUnsafe.stdout.length > 0 || !worktreeConfigKnown) {
|
||||
throw new Error("GitHub publication workspace has unsupported Git transport configuration.");
|
||||
}
|
||||
const worktreeUnsafe =
|
||||
worktreeConfigValue === "true"
|
||||
? await run(githubPublicationUnsafeConfigArgs("--worktree"), {
|
||||
cwd,
|
||||
env: isolatedConfig,
|
||||
})
|
||||
: undefined;
|
||||
if (worktreeUnsafe && (worktreeUnsafe.code !== 1 || worktreeUnsafe.stdout.length > 0)) {
|
||||
throw new Error("GitHub publication workspace has unsupported Git transport configuration.");
|
||||
}
|
||||
const [replacements, graftPath] = await Promise.all([
|
||||
run(["git", "for-each-ref", "--count=1", "--format=%(refname)", "refs/replace"], { cwd }),
|
||||
run(["git", "rev-parse", "--git-path", "info/grafts"], { cwd }),
|
||||
]);
|
||||
if (replacements.code !== 0 || replacements.stdout.length > 0 || graftPath.code !== 0) {
|
||||
throw new Error("GitHub publication workspace has unsupported Git replacement metadata.");
|
||||
}
|
||||
const grafts = await readOptionalAttributeFile(
|
||||
path.resolve(cwd, graftPath.stdout.toString("utf8").trim()),
|
||||
);
|
||||
if (grafts && grafts.length > 0) {
|
||||
throw new Error("GitHub publication workspace has unsupported Git replacement metadata.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoGitFilterAttributes(contents: Buffer): void {
|
||||
for (const line of contents.toString("latin1").split(/\r?\n/u)) {
|
||||
const fields = line.trimStart().split(/[\t ]+/u);
|
||||
if (!fields[0] || fields[0].startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (fields.slice(1).some((field) => /^(?:-|!)?filter(?:=|$)/u.test(field))) {
|
||||
throw new Error("GitHub publication workspace uses an unsupported Git clean filter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalAttributeFile(file: string): Promise<Buffer | undefined> {
|
||||
try {
|
||||
return await fs.readFile(file);
|
||||
} catch (error) {
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
||||
if (code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertGitHubPublicationTreeHasNoFilters(
|
||||
cwd: string,
|
||||
workspaceTree: string,
|
||||
run: (argv: string[], options?: GitCommandOptions) => Promise<GitCommandResult>,
|
||||
): Promise<void> {
|
||||
const listing = await run(["git", "ls-tree", "-r", "-z", "--full-tree", workspaceTree], { cwd });
|
||||
if (listing.code !== 0) {
|
||||
throw new Error("GitHub publication workspace attributes could not be verified.");
|
||||
}
|
||||
const attributeObjects = new Set<string>();
|
||||
for (const record of listing.stdout.toString("latin1").split("\0")) {
|
||||
const tab = record.indexOf("\t");
|
||||
if (tab < 0) {
|
||||
continue;
|
||||
}
|
||||
const file = record.slice(tab + 1).toLowerCase();
|
||||
if (file !== ".gitattributes" && !file.endsWith("/.gitattributes")) {
|
||||
continue;
|
||||
}
|
||||
const objectId = record.slice(0, tab).split(" ")[2];
|
||||
if (objectId) {
|
||||
attributeObjects.add(objectId);
|
||||
}
|
||||
}
|
||||
if (attributeObjects.size > 1024) {
|
||||
throw new Error("GitHub publication workspace has too many Git attribute files.");
|
||||
}
|
||||
for (const objectId of attributeObjects) {
|
||||
const blob = await run(["git", "cat-file", "blob", objectId], { cwd });
|
||||
if (blob.code !== 0) {
|
||||
throw new Error("GitHub publication workspace attributes could not be verified.");
|
||||
}
|
||||
assertNoGitFilterAttributes(blob.stdout);
|
||||
}
|
||||
|
||||
const infoPath = await run(["git", "rev-parse", "--git-path", "info/attributes"], {
|
||||
cwd,
|
||||
});
|
||||
if (infoPath.code !== 0) {
|
||||
throw new Error("GitHub publication workspace attributes could not be verified.");
|
||||
}
|
||||
const attributeFiles = await Promise.all(
|
||||
["GIT_ATTR_GLOBAL", "GIT_ATTR_SYSTEM"].map(
|
||||
async (name) => await run(["git", "var", name], { cwd }),
|
||||
),
|
||||
);
|
||||
if (attributeFiles.some((result) => result.code !== 0)) {
|
||||
throw new Error("GitHub publication workspace attributes could not be verified.");
|
||||
}
|
||||
const paths = [
|
||||
path.resolve(cwd, infoPath.stdout.toString("utf8").trim()),
|
||||
...attributeFiles.flatMap((result) =>
|
||||
result.stdout.length > 0 ? [result.stdout.toString("utf8").trim()] : [],
|
||||
),
|
||||
];
|
||||
for (const file of paths) {
|
||||
const contents = await readOptionalAttributeFile(file);
|
||||
if (contents) {
|
||||
assertNoGitFilterAttributes(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GITHUB_CREDENTIAL_ARGS = [
|
||||
"git",
|
||||
"-c",
|
||||
"credential.helper=",
|
||||
"-c",
|
||||
"credential.helper=!gh auth git-credential",
|
||||
] as const;
|
||||
|
||||
export function appendGitHubPublicationMessage(base: string, lines: readonly string[]): string {
|
||||
const present = new Set(base.split(/\r?\n/u).map((line) => line.trim()));
|
||||
const missing = lines.filter((line) => !present.has(line));
|
||||
return missing.length > 0 ? `${base.trimEnd()}\n\n${missing.join("\n")}` : base.trimEnd();
|
||||
}
|
||||
|
||||
export async function assertGitHubPublicationBranchRef(
|
||||
branch: string,
|
||||
run: (argv: string[]) => Promise<number>,
|
||||
): Promise<void> {
|
||||
const code = await run(["git", "symbolic-ref", "--quiet", `refs/heads/${branch}`]);
|
||||
if (code === 0) {
|
||||
throw new Error("GitHub publication workspace branch ref became symbolic.");
|
||||
}
|
||||
if (code !== 1) {
|
||||
throw new Error("GitHub publication workspace branch ref could not be verified.");
|
||||
}
|
||||
}
|
||||
|
||||
export function githubPublicationPushArgs(
|
||||
remote: string,
|
||||
headCommit: string,
|
||||
branch: string,
|
||||
): string[] {
|
||||
return [
|
||||
...GITHUB_CREDENTIAL_ARGS,
|
||||
"push",
|
||||
"--porcelain",
|
||||
"--no-follow-tags",
|
||||
"--recurse-submodules=no",
|
||||
"--",
|
||||
remote,
|
||||
`${headCommit}:refs/heads/${branch}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function githubPublicationRemoteHeadArgs(remote: string, branch: string): string[] {
|
||||
return [...GITHUB_CREDENTIAL_ARGS, "ls-remote", "--refs", remote, `refs/heads/${branch}`];
|
||||
}
|
||||
|
||||
export function githubPublicationUpdateRefArgs(
|
||||
branch: string,
|
||||
commit: string,
|
||||
previousHead: string,
|
||||
): string[] {
|
||||
return [
|
||||
"git",
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"update-ref",
|
||||
`refs/heads/${branch}`,
|
||||
commit,
|
||||
previousHead,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
type GitHubPublicationPullRequest = {
|
||||
userId: number;
|
||||
url: string;
|
||||
state: "open" | "closed";
|
||||
body: string;
|
||||
headSha: string;
|
||||
headRef: string;
|
||||
baseRef: string;
|
||||
};
|
||||
|
||||
export function githubPublicationPullRequestLookupArgs(params: {
|
||||
repository: string;
|
||||
owner: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
}): string[] {
|
||||
return [
|
||||
"gh",
|
||||
"api",
|
||||
"--hostname",
|
||||
"github.com",
|
||||
"--method",
|
||||
"GET",
|
||||
`repos/${params.repository}/pulls`,
|
||||
"-f",
|
||||
`head=${params.owner}:${params.branch}`,
|
||||
"-f",
|
||||
`base=${params.baseBranch}`,
|
||||
"-f",
|
||||
"state=all",
|
||||
"--jq",
|
||||
'map({url: .html_url, userId: .user.id, state: .state, body: (.body // ""), headSha: .head.sha, headRef: .head.ref, baseRef: .base.ref})',
|
||||
];
|
||||
}
|
||||
|
||||
export function githubPublicationCreatePullRequestArgs(repository: string): string[] {
|
||||
return [
|
||||
"gh",
|
||||
"api",
|
||||
"--hostname",
|
||||
"github.com",
|
||||
"--method",
|
||||
"POST",
|
||||
`repos/${repository}/pulls`,
|
||||
"--input",
|
||||
"-",
|
||||
];
|
||||
}
|
||||
|
||||
/** Parses the complete authenticated PR lookup; one malformed candidate invalidates the response. */
|
||||
export function parseGitHubPublicationPullRequests(raw: string): GitHubPublicationPullRequest[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error("GitHub pull request lookup returned invalid JSON.", { cause: error });
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("GitHub pull request lookup returned an invalid response.");
|
||||
}
|
||||
return parsed.map((candidate) => {
|
||||
if (!isRecord(candidate)) {
|
||||
throw new Error("GitHub pull request lookup returned an invalid candidate.");
|
||||
}
|
||||
const userId = candidate.userId;
|
||||
const url = readNonBlankString(candidate.url);
|
||||
const state = candidate.state;
|
||||
const body = candidate.body;
|
||||
const headSha = readNonBlankString(candidate.headSha);
|
||||
const headRef = readNonBlankString(candidate.headRef);
|
||||
const baseRef = readNonBlankString(candidate.baseRef);
|
||||
if (
|
||||
!Number.isSafeInteger(userId) ||
|
||||
Number(userId) < 1 ||
|
||||
!url ||
|
||||
(state !== "open" && state !== "closed") ||
|
||||
typeof body !== "string" ||
|
||||
!headSha ||
|
||||
!headRef ||
|
||||
!baseRef
|
||||
) {
|
||||
throw new Error("GitHub pull request lookup returned an invalid candidate.");
|
||||
}
|
||||
return { userId: Number(userId), url, state, body, headSha, headRef, baseRef };
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGitHubPublicationPullRequestUrl(
|
||||
candidates: readonly GitHubPublicationPullRequest[],
|
||||
params: {
|
||||
accountId: number;
|
||||
headCommit: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
marker: string;
|
||||
},
|
||||
): string | undefined {
|
||||
const exact = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.userId === params.accountId &&
|
||||
candidate.headSha === params.headCommit &&
|
||||
candidate.headRef === params.branch &&
|
||||
candidate.baseRef === params.baseBranch,
|
||||
);
|
||||
const open = exact.find((candidate) => candidate.state === "open");
|
||||
if (open) {
|
||||
return open.url;
|
||||
}
|
||||
if (
|
||||
exact.some(
|
||||
(candidate) => candidate.state === "closed" && candidate.body.includes(params.marker),
|
||||
)
|
||||
) {
|
||||
throw new Error("GitHub pull request was closed before publication completed.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { managedWorktrees } from "../agents/worktrees/service.js";
|
||||
import type { DB as StateDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import { recoverGitHubPublicationBranchAndIndex } from "./github-publication-git-index.js";
|
||||
|
||||
type PublicationRow = StateDatabase["github_publication_requests"];
|
||||
type GitCommandOptions = { cwd?: string; env?: NodeJS.ProcessEnv; input?: string };
|
||||
|
||||
export async function recoverGitHubPublicationWorkspace(
|
||||
row: PublicationRow,
|
||||
run: (argv: string[], options?: GitCommandOptions) => Promise<string>,
|
||||
assertCurrent: () => void,
|
||||
): Promise<void> {
|
||||
const worktree = managedWorktrees.findLiveById(row.worktree_id);
|
||||
if (
|
||||
worktree?.repoFingerprint !== row.repository_fingerprint ||
|
||||
worktree.branch !== row.branch ||
|
||||
!row.source_head_commit ||
|
||||
!row.workspace_tree
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await recoverGitHubPublicationBranchAndIndex({
|
||||
cwd: worktree.path,
|
||||
requestId: row.request_id,
|
||||
branch: row.branch,
|
||||
sourceHeadCommit: row.source_head_commit,
|
||||
workspaceTree: row.workspace_tree,
|
||||
assertCurrent,
|
||||
run,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { createGitHubPublicationTranscriptReporter } from "./github-publication-transcript.js";
|
||||
import { createGitHubPublicationCoordinator } from "./github-publication.js";
|
||||
import type {
|
||||
WorkerSessionPlacementStore,
|
||||
WorkerSessionTurnClaim,
|
||||
} from "./worker-environments/placement-store.js";
|
||||
|
||||
export function createGitHubPublicationRuntime(params: {
|
||||
placements: WorkerSessionPlacementStore;
|
||||
loadSessionRuntime: Parameters<typeof createGitHubPublicationTranscriptReporter>[0];
|
||||
warn: (message: string) => void;
|
||||
}) {
|
||||
const coordinator = createGitHubPublicationCoordinator({ placements: params.placements });
|
||||
const report = createGitHubPublicationTranscriptReporter(params.loadSessionRuntime, coordinator);
|
||||
const reportDeferred = async (publication: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
result: Parameters<typeof report>[0]["result"];
|
||||
}) => {
|
||||
try {
|
||||
await report(publication);
|
||||
} catch (error) {
|
||||
params.warn(
|
||||
`GitHub publication result reporting deferred for ${publication.sessionId}: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const prepareAcceptedWorkspacePublication = async (claim: WorkerSessionTurnClaim) => {
|
||||
try {
|
||||
await coordinator.prepareClaimWorkspace(claim);
|
||||
} catch (error) {
|
||||
coordinator.failClaimPreparation(claim, error);
|
||||
}
|
||||
};
|
||||
const publishAcceptedWorkspace = async (claim: WorkerSessionTurnClaim) => {
|
||||
const placement = params.placements.get(claim.sessionId);
|
||||
if (!placement) {
|
||||
params.warn(`GitHub publication deferred because placement ${claim.sessionId} disappeared.`);
|
||||
return;
|
||||
}
|
||||
let results;
|
||||
try {
|
||||
results = await coordinator.processClaim(claim);
|
||||
} catch (error) {
|
||||
params.warn(
|
||||
`GitHub publication deferred for ${claim.sessionId}: ${formatErrorMessage(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
for (const result of results) {
|
||||
await reportDeferred({
|
||||
sessionId: placement.sessionId,
|
||||
sessionKey: placement.sessionKey,
|
||||
agentId: placement.agentId,
|
||||
result,
|
||||
});
|
||||
}
|
||||
};
|
||||
const reconcilePublications = async () => {
|
||||
try {
|
||||
await coordinator.resumeLocalRequests();
|
||||
} catch (error) {
|
||||
params.warn(`GitHub publication recovery deferred: ${formatErrorMessage(error)}`);
|
||||
}
|
||||
const attempted = new Set<string>();
|
||||
for (const publication of coordinator.failOrphanedRequests()) {
|
||||
attempted.add(publication.result.requestId);
|
||||
await reportDeferred(publication);
|
||||
}
|
||||
for (const publication of coordinator.listUnreportedResults()) {
|
||||
if (!attempted.has(publication.result.requestId)) {
|
||||
await reportDeferred(publication);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
coordinator,
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
reconcilePublications,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import { ensureGitHubPublicationSchema } from "../state/openclaw-state-db-schema-additive.js";
|
||||
import { tableExists } from "../state/openclaw-state-db-schema-helpers.js";
|
||||
import type { DB as StateDatabase } from "../state/openclaw-state-db.generated.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
|
||||
type GitHubPublicationDatabase = Pick<
|
||||
StateDatabase,
|
||||
"github_publication_requests" | "worker_session_placements"
|
||||
>;
|
||||
export type GitHubPublicationRow = StateDatabase["github_publication_requests"];
|
||||
type PublicationFailureCode = Extract<SessionGitHubPublicationResult, { status: "failed" }>["code"];
|
||||
|
||||
const PUBLICATION_FAILURE_CODES = new Set<string>([
|
||||
"identity_changed",
|
||||
"identity_unavailable",
|
||||
"session_changed",
|
||||
"workspace_changed",
|
||||
"not_git",
|
||||
"not_github",
|
||||
"no_changes",
|
||||
"push_rejected",
|
||||
"github_rejected",
|
||||
"unavailable",
|
||||
]);
|
||||
|
||||
function publicationFailureCode(value: string): PublicationFailureCode {
|
||||
// SAFETY: membership in the closed protocol vocabulary narrows this stored string.
|
||||
return PUBLICATION_FAILURE_CODES.has(value) ? (value as PublicationFailureCode) : "unavailable";
|
||||
}
|
||||
|
||||
export const githubPublicationDatabase = (db: Parameters<typeof getNodeSqliteKysely>[0]) =>
|
||||
getNodeSqliteKysely<GitHubPublicationDatabase>(db);
|
||||
|
||||
export function ensureGitHubPublicationStore(): void {
|
||||
ensureGitHubPublicationSchema(openOpenClawStateDatabase().db);
|
||||
}
|
||||
|
||||
export function hasGitHubPublicationStore(): boolean {
|
||||
return tableExists(openOpenClawStateDatabase().db, "github_publication_requests");
|
||||
}
|
||||
|
||||
export function claimGitHubPublicationExecution(
|
||||
requestId: string,
|
||||
gatewayInstanceId: string,
|
||||
): GitHubPublicationRow {
|
||||
return runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const query = githubPublicationDatabase(db);
|
||||
const current = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", requestId),
|
||||
).rows[0];
|
||||
if (!current) {
|
||||
throw new Error("GitHub publication request disappeared.");
|
||||
}
|
||||
if (current.status === "published" || current.status === "failed") {
|
||||
return current;
|
||||
}
|
||||
let update = query
|
||||
.updateTable("github_publication_requests")
|
||||
.set({
|
||||
status: "publishing",
|
||||
gateway_instance_id: gatewayInstanceId,
|
||||
updated_at_ms: Date.now(),
|
||||
})
|
||||
.where("request_id", "=", current.request_id)
|
||||
.where("status", "=", current.status);
|
||||
update = current.gateway_instance_id
|
||||
? update.where("gateway_instance_id", "=", current.gateway_instance_id)
|
||||
: update.where("gateway_instance_id", "is", null);
|
||||
const claimed = executeSqliteQuerySync(db, update);
|
||||
if (claimed.numAffectedRows !== 1n) {
|
||||
throw new Error("GitHub publication execution ownership changed.");
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", requestId),
|
||||
).rows[0]!;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.claim" },
|
||||
);
|
||||
}
|
||||
|
||||
export function isGitHubPublicationExecutionOwner(
|
||||
requestId: string,
|
||||
gatewayInstanceId: string,
|
||||
): boolean {
|
||||
ensureGitHubPublicationStore();
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const row = executeSqliteQuerySync(
|
||||
db,
|
||||
githubPublicationDatabase(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.select(["status", "gateway_instance_id"])
|
||||
.where("request_id", "=", requestId),
|
||||
).rows[0];
|
||||
return row?.status === "publishing" && row.gateway_instance_id === gatewayInstanceId;
|
||||
}
|
||||
|
||||
export function digestGitHubPublicationRequest(params: {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
}): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
sessionId: params.sessionId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
title: params.title ?? null,
|
||||
body: params.body ?? null,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function projectGitHubPublicationResult(
|
||||
row: GitHubPublicationRow,
|
||||
): SessionGitHubPublicationResult {
|
||||
if (row.status === "published" && row.pull_request_url && row.repository && row.branch) {
|
||||
return {
|
||||
requestId: row.request_id,
|
||||
status: "published",
|
||||
url: row.pull_request_url,
|
||||
repository: row.repository,
|
||||
branch: row.branch,
|
||||
headCommit: row.head_commit ?? "unknown",
|
||||
};
|
||||
}
|
||||
if (row.status === "failed" && row.error_code && row.next_action) {
|
||||
return {
|
||||
requestId: row.request_id,
|
||||
status: "failed",
|
||||
code: publicationFailureCode(row.error_code),
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: row.next_action,
|
||||
};
|
||||
}
|
||||
return {
|
||||
requestId: row.request_id,
|
||||
status: row.status === "publishing" ? "publishing" : "requested",
|
||||
message:
|
||||
row.status === "publishing"
|
||||
? "The Gateway is publishing the reconciled workspace."
|
||||
: "Publication was accepted. Finish the turn so the Gateway can reconcile and publish the workspace.",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
loadTranscriptEvents,
|
||||
upsertSessionEntryCore,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js";
|
||||
import { createGitHubPublicationTranscriptReporter } from "./github-publication-transcript.js";
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
describe("GitHub publication transcript reporting", () => {
|
||||
it.each([
|
||||
{
|
||||
label: "published",
|
||||
result: {
|
||||
requestId: "publication-success",
|
||||
status: "published",
|
||||
url: "https://github.com/openclaw/openclaw/pull/1",
|
||||
repository: "openclaw/openclaw",
|
||||
branch: "openclaw/task",
|
||||
headCommit: "a".repeat(40),
|
||||
} satisfies SessionGitHubPublicationResult,
|
||||
visibleText: "https://github.com/openclaw/openclaw/pull/1",
|
||||
},
|
||||
{
|
||||
label: "failed",
|
||||
result: {
|
||||
requestId: "publication-failure",
|
||||
status: "failed",
|
||||
code: "push_rejected",
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: "Check repository write access and retry.",
|
||||
} satisfies SessionGitHubPublicationResult,
|
||||
visibleText: "Check repository write access and retry.",
|
||||
},
|
||||
])(
|
||||
"appends one projected assistant message for a $label result",
|
||||
async ({ result, visibleText }) => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const sessionId = "publication-transcript";
|
||||
await upsertSessionEntryCore({ agentId: "main", sessionKey }, { sessionId, updatedAt: 1 });
|
||||
const markReported = vi.fn();
|
||||
const reporter = createGitHubPublicationTranscriptReporter(
|
||||
async () => {
|
||||
const runtime = await import("./session-utils.js");
|
||||
return {
|
||||
resolveCanonicalSessionEntryFromStoreKeys:
|
||||
runtime.resolveCanonicalSessionEntryFromStoreKeys,
|
||||
resolveGatewaySessionStoreTargetWithStore:
|
||||
runtime.resolveGatewaySessionStoreTargetWithStore,
|
||||
};
|
||||
},
|
||||
{ markReported },
|
||||
);
|
||||
|
||||
await reporter({ sessionId, sessionKey, agentId: "main", result });
|
||||
await reporter({ sessionId, sessionKey, agentId: "main", result });
|
||||
|
||||
const events = await loadTranscriptEvents({ agentId: "main", sessionId, sessionKey });
|
||||
const messages = events.filter(
|
||||
(event) =>
|
||||
isRecord(event) &&
|
||||
event.type === "message" &&
|
||||
isRecord(event.message) &&
|
||||
event.message.role === "assistant" &&
|
||||
event.message.responseId === `github-publication:${result.requestId}`,
|
||||
);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(JSON.stringify(messages[0])).toContain(visibleText);
|
||||
expect(markReported).toHaveBeenCalledWith(result.requestId);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import { SessionManager } from "../agents/sessions/session-manager.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { withTranscriptWriteTransaction } from "../config/sessions/session-accessor.js";
|
||||
import type { GitHubPublicationCoordinator } from "./github-publication.js";
|
||||
|
||||
const GITHUB_PUBLICATION_RESPONSE_PREFIX = "github-publication:";
|
||||
|
||||
function formatGitHubPublicationResult(result: SessionGitHubPublicationResult): string {
|
||||
switch (result.status) {
|
||||
case "published":
|
||||
return `Published ${result.repository} branch ${result.branch}: ${result.url}`;
|
||||
case "failed":
|
||||
return `GitHub publication failed: ${result.message} ${result.nextAction}`;
|
||||
case "publishing":
|
||||
case "requested":
|
||||
return result.message;
|
||||
}
|
||||
return result satisfies never;
|
||||
}
|
||||
|
||||
export function createGitHubPublicationTranscriptReporter(
|
||||
loadSessionRuntime: () => Promise<{
|
||||
resolveCanonicalSessionEntryFromStoreKeys: typeof import("./session-utils.js").resolveCanonicalSessionEntryFromStoreKeys;
|
||||
resolveGatewaySessionStoreTargetWithStore: typeof import("./session-utils.js").resolveGatewaySessionStoreTargetWithStore;
|
||||
}>,
|
||||
coordinator: Pick<GitHubPublicationCoordinator, "markReported">,
|
||||
) {
|
||||
return async (params: {
|
||||
sessionId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
result: SessionGitHubPublicationResult;
|
||||
}): Promise<void> => {
|
||||
const runtime = await loadSessionRuntime();
|
||||
const target = runtime.resolveGatewaySessionStoreTargetWithStore({
|
||||
cfg: getRuntimeConfig(),
|
||||
key: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
clone: false,
|
||||
});
|
||||
const entry = runtime.resolveCanonicalSessionEntryFromStoreKeys(target.store, target.storeKeys);
|
||||
if (entry?.sessionId !== params.sessionId || target.canonicalKey !== params.sessionKey) {
|
||||
throw new Error("GitHub publication transcript owner changed");
|
||||
}
|
||||
await withTranscriptWriteTransaction(
|
||||
{
|
||||
agentId: target.agentId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: target.canonicalKey,
|
||||
storePath: target.storePath,
|
||||
},
|
||||
(transcriptTarget) => {
|
||||
const manager = SessionManager.open(transcriptTarget);
|
||||
const exists = manager.getBranch().some((transcriptEntry) => {
|
||||
return (
|
||||
transcriptEntry.type === "message" &&
|
||||
transcriptEntry.message.role === "assistant" &&
|
||||
transcriptEntry.message.responseId ===
|
||||
`${GITHUB_PUBLICATION_RESPONSE_PREFIX}${params.result.requestId}`
|
||||
);
|
||||
});
|
||||
if (!exists) {
|
||||
manager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: formatGitHubPublicationResult(params.result) }],
|
||||
api: "openai-responses",
|
||||
provider: "openclaw",
|
||||
model: "gateway-publication",
|
||||
responseId: `${GITHUB_PUBLICATION_RESPONSE_PREFIX}${params.result.requestId}`,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
coordinator.markReported(params.result.requestId);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
type OpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { createGitHubPublicationRuntime as createRuntime } from "./github-publication-runtime.js";
|
||||
import { createGitHubPublicationCoordinator as createCoordinator } from "./github-publication.js";
|
||||
import { REQUEST } from "./worker-environments/placement-dispatch-test-fixtures.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
matchesIdentity: vi.fn(),
|
||||
prepareIdentity: vi.fn(),
|
||||
runCommand: vi.fn(),
|
||||
findWorktree: vi.fn(),
|
||||
findWorktreeById: vi.fn(),
|
||||
resolveRepository: vi.fn(),
|
||||
loadSession: vi.fn(),
|
||||
getConfigSnapshot: vi.fn(),
|
||||
attribution: vi.fn(),
|
||||
updateIndex: vi.fn(),
|
||||
}));
|
||||
|
||||
export function githubPublicationTestMocks() {
|
||||
return mocks;
|
||||
}
|
||||
|
||||
vi.mock("../agents/github-tool-identity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../agents/github-tool-identity.js")>();
|
||||
return {
|
||||
...actual,
|
||||
matchesPreparedGitHubPublicationIdentity: mocks.matchesIdentity,
|
||||
prepareGitHubPublicationIdentity: mocks.prepareIdentity,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../agents/git-coauthor-attribution.js", () => ({
|
||||
resolveGitCoauthorAttribution: mocks.attribution,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/worktrees/service.js", () => ({
|
||||
managedWorktrees: {
|
||||
findLiveByOwner: mocks.findWorktree,
|
||||
findLiveById: mocks.findWorktreeById,
|
||||
resolveRepositoryIdentity: mocks.resolveRepository,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./session-utils.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./session-utils.js")>()),
|
||||
loadGatewaySessionEntryReadOnly: mocks.loadSession,
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandBuffered: mocks.runCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../secrets/runtime-state.js", () => ({
|
||||
getActiveSecretsRuntimeConfigSnapshot: mocks.getConfigSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("./github-publication-git-index.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./github-publication-git-index.js")>()),
|
||||
updateGitHubPublicationBranchAndIndex: mocks.updateIndex,
|
||||
}));
|
||||
|
||||
export function createTestGitHubPublicationRuntime(...args: Parameters<typeof createRuntime>) {
|
||||
return createRuntime(...args);
|
||||
}
|
||||
|
||||
export function createTestGitHubPublicationCoordinator(
|
||||
...args: Parameters<typeof createCoordinator>
|
||||
) {
|
||||
return createCoordinator(...args);
|
||||
}
|
||||
|
||||
export const SESSION_KEY = "agent:main:dashboard:publication";
|
||||
export const SESSION_ID = "session-publication";
|
||||
export const BRANCH = "openclaw/publication";
|
||||
export const BASE_HEAD = "a".repeat(40);
|
||||
export const OLD_HEAD = "b".repeat(40);
|
||||
export const NEW_HEAD = "c".repeat(40);
|
||||
export const WORKSPACE_TREE = "d".repeat(40);
|
||||
const BASE_TREE = "e".repeat(40);
|
||||
|
||||
export function commandResult(stdout = "", code = 0) {
|
||||
return {
|
||||
code,
|
||||
signal: null,
|
||||
killed: false,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.alloc(0),
|
||||
};
|
||||
}
|
||||
|
||||
export function seedLocalPublication(
|
||||
database: OpenClawStateDatabase,
|
||||
params: {
|
||||
requestId: string;
|
||||
status: "requested" | "publishing";
|
||||
repositoryFingerprint?: string;
|
||||
headCommit?: string;
|
||||
},
|
||||
): void {
|
||||
database.db
|
||||
.prepare(
|
||||
`INSERT INTO github_publication_requests (
|
||||
request_id, idempotency_key, request_digest, session_id, session_key, agent_id,
|
||||
worktree_id, repository_fingerprint, claim_id, run_id, environment_id, owner_epoch,
|
||||
placement_generation, identity_source, identity_profile_id, identity_account_id,
|
||||
identity_login, title, body, status, gateway_instance_id, repository, branch,
|
||||
base_branch, source_head_commit, source_index_tree, workspace_tree, head_commit,
|
||||
pull_request_url,
|
||||
error_code, next_action, created_at_ms, updated_at_ms, reported_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?,
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, NULL)`,
|
||||
)
|
||||
.run(
|
||||
params.requestId,
|
||||
`idempotency-${params.requestId}`,
|
||||
`digest-${params.requestId}`,
|
||||
SESSION_ID,
|
||||
SESSION_KEY,
|
||||
"main",
|
||||
"worktree-1",
|
||||
params.repositoryFingerprint ?? "fingerprint-1",
|
||||
"system-configured",
|
||||
"ghp_11111111111111111111111111111111",
|
||||
42,
|
||||
"roboclaw-bot",
|
||||
"Resume the publication",
|
||||
"Recovered after Gateway restart.",
|
||||
params.status,
|
||||
"previous-gateway-instance",
|
||||
"openclaw/openclaw",
|
||||
BRANCH,
|
||||
"main",
|
||||
OLD_HEAD,
|
||||
WORKSPACE_TREE,
|
||||
WORKSPACE_TREE,
|
||||
params.headCommit ?? NEW_HEAD,
|
||||
1_000,
|
||||
1_001,
|
||||
);
|
||||
}
|
||||
|
||||
export function publicationTranscriptMessages(events: unknown[], requestId: string) {
|
||||
return events.filter(
|
||||
(event) =>
|
||||
isRecord(event) &&
|
||||
event.type === "message" &&
|
||||
isRecord(event.message) &&
|
||||
event.message.role === "assistant" &&
|
||||
event.message.responseId === `github-publication:${requestId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export let root: string;
|
||||
export let commands: string[][];
|
||||
export let commandCalls: Array<{ argv: string[]; input?: string }>;
|
||||
|
||||
export function installGitHubPublicationTestHarness(): void {
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "openclaw-publication-"));
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", root);
|
||||
commands = [];
|
||||
commandCalls = [];
|
||||
mocks.updateIndex
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
async (params: {
|
||||
cwd: string;
|
||||
sourceIndexTree: string;
|
||||
workspaceTree: string;
|
||||
run: (argv: string[], options?: { cwd?: string }) => Promise<string>;
|
||||
updateRef?: () => Promise<void>;
|
||||
}) => {
|
||||
const currentIndexTree = await params.run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
`core.hooksPath=${os.devNull}`,
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"write-tree",
|
||||
],
|
||||
{ cwd: params.cwd },
|
||||
);
|
||||
if (
|
||||
currentIndexTree !== params.sourceIndexTree &&
|
||||
currentIndexTree !== params.workspaceTree
|
||||
) {
|
||||
throw new Error(
|
||||
"GitHub publication workspace index changed after its accepted snapshot.",
|
||||
);
|
||||
}
|
||||
await params.updateRef?.();
|
||||
},
|
||||
);
|
||||
mocks.attribution.mockReset().mockReturnValue({
|
||||
trailers: ["Co-authored-by: alice <7+alice@users.noreply.github.com>"],
|
||||
logins: ["alice"],
|
||||
prompt: "",
|
||||
});
|
||||
mocks.getConfigSnapshot.mockReset().mockReturnValue(null);
|
||||
mocks.matchesIdentity.mockReset().mockReturnValue(true);
|
||||
mocks.prepareIdentity.mockReset().mockResolvedValue({
|
||||
source: "system-configured",
|
||||
profileId: "ghp_11111111111111111111111111111111",
|
||||
account: { accountId: 42, login: "roboclaw-bot", avatarUrl: null },
|
||||
env: {
|
||||
GH_CONFIG_DIR: "/private/github-profile",
|
||||
GH_TOKEN: undefined,
|
||||
GITHUB_TOKEN: undefined,
|
||||
},
|
||||
});
|
||||
mocks.findWorktree.mockReset().mockImplementation((_ownerKind, ownerId: string) => ({
|
||||
id: "worktree-1",
|
||||
repoRoot: "/repo",
|
||||
repoFingerprint: "fingerprint-1",
|
||||
path: "/repo/worktree",
|
||||
branch: BRANCH,
|
||||
baseRef: "origin/main",
|
||||
ownerKind: "session",
|
||||
ownerId,
|
||||
}));
|
||||
mocks.findWorktreeById.mockReset().mockReturnValue(undefined);
|
||||
mocks.resolveRepository.mockReset().mockResolvedValue({
|
||||
checkoutRoot: "/repo/worktree",
|
||||
repoRoot: "/repo",
|
||||
originUrl: "git@github.com:openclaw/openclaw.git",
|
||||
fingerprint: "fingerprint-1",
|
||||
});
|
||||
mocks.loadSession.mockReset().mockImplementation((sessionKey: string) => ({
|
||||
canonicalKey: sessionKey,
|
||||
agentId: "main",
|
||||
storePath: "/state/sessions.json",
|
||||
entry: {
|
||||
sessionId: sessionKey === REQUEST.sessionKey ? REQUEST.sessionId : SESSION_ID,
|
||||
worktree: { id: "worktree-1", branch: BRANCH, repoRoot: "/repo" },
|
||||
},
|
||||
}));
|
||||
let remoteLookup = 0;
|
||||
mocks.runCommand
|
||||
.mockReset()
|
||||
.mockImplementation(async (argv: string[], options?: { input?: string }) => {
|
||||
commands.push(argv);
|
||||
commandCalls.push({ argv, input: options?.input });
|
||||
const command = argv.join(" ");
|
||||
if (command === "git symbolic-ref --quiet --short HEAD") {
|
||||
return commandResult(`${BRANCH}\n`);
|
||||
}
|
||||
if (command === `git symbolic-ref --quiet refs/heads/${BRANCH}`) {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
if (command === "git rev-parse --verify HEAD^{commit}") {
|
||||
return commandResult(`${OLD_HEAD}\n`);
|
||||
}
|
||||
if (
|
||||
command.startsWith(
|
||||
"gh api --hostname github.com repos/openclaw/openclaw --jq {fork, default_branch, parent:",
|
||||
)
|
||||
) {
|
||||
return commandResult('{"fork":false,"default_branch":"main"}\n');
|
||||
}
|
||||
const baseRefPrefix = "gh api --hostname github.com repos/openclaw/openclaw/git/ref/heads/";
|
||||
if (command.startsWith(baseRefPrefix)) {
|
||||
const branch = command.slice(baseRefPrefix.length).split(" --jq", 1)[0];
|
||||
return commandResult(JSON.stringify({ ref: `refs/heads/${branch}`, sha: BASE_HEAD }));
|
||||
}
|
||||
if (command === "git show -s --format=%B HEAD") {
|
||||
return commandResult("existing commit\n");
|
||||
}
|
||||
if (command === "git config --local --includes --bool --get extensions.worktreeConfig") {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
if (argv.includes("--includes") && argv.includes("--get-regexp")) {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
if (command.startsWith("git ls-tree -r -z --full-tree ")) {
|
||||
return commandResult();
|
||||
}
|
||||
if (command === "git rev-parse --git-path info/attributes") {
|
||||
return commandResult(path.join(root, "missing-info-attributes"));
|
||||
}
|
||||
if (command === "git rev-parse --git-path info/grafts") {
|
||||
return commandResult(path.join(root, "missing-grafts"));
|
||||
}
|
||||
if (command === "git var GIT_ATTR_GLOBAL" || command === "git var GIT_ATTR_SYSTEM") {
|
||||
return commandResult(path.join(root, "missing-global-attributes"));
|
||||
}
|
||||
if (
|
||||
argv[0] === "git" &&
|
||||
argv[1] === "config" &&
|
||||
(argv.includes("--global") || argv.includes("--system")) &&
|
||||
argv.includes("core.attributesFile")
|
||||
) {
|
||||
return commandResult("", 1);
|
||||
}
|
||||
if (command.endsWith("write-tree")) {
|
||||
return commandResult(`${WORKSPACE_TREE}\n`);
|
||||
}
|
||||
if (command === "git rev-parse HEAD^{tree}") {
|
||||
return commandResult(`${WORKSPACE_TREE}\n`);
|
||||
}
|
||||
if (command === `git rev-parse ${BASE_HEAD}^{tree}`) {
|
||||
return commandResult(`${BASE_TREE}\n`);
|
||||
}
|
||||
if (command === "git rev-parse HEAD^") {
|
||||
return commandResult(`${OLD_HEAD}\n`);
|
||||
}
|
||||
if (command === `git reflog show --format=%H --end-of-options refs/heads/${BRANCH}`) {
|
||||
return commandResult(`${NEW_HEAD}\n${OLD_HEAD}\n`);
|
||||
}
|
||||
if (command.startsWith("git commit-tree ")) {
|
||||
return commandResult(`${NEW_HEAD}\n`);
|
||||
}
|
||||
if (command === "git rev-parse --verify --end-of-options origin/main^{commit}") {
|
||||
return commandResult(`${BASE_HEAD}\n`);
|
||||
}
|
||||
if (
|
||||
command.startsWith(
|
||||
"git -c credential.helper= -c credential.helper=!gh auth git-credential ls-remote",
|
||||
)
|
||||
) {
|
||||
remoteLookup += 1;
|
||||
return commandResult(remoteLookup === 1 ? "" : `${NEW_HEAD}\trefs/heads/${BRANCH}\n`);
|
||||
}
|
||||
if (command.includes(" repos/openclaw/openclaw/pulls ") && command.includes("state=all")) {
|
||||
return commandResult("[]\n");
|
||||
}
|
||||
if (
|
||||
command ===
|
||||
"gh api --hostname github.com --method POST repos/openclaw/openclaw/pulls --input -"
|
||||
) {
|
||||
return commandResult('{"html_url":"https://github.com/openclaw/openclaw/pull/125200"}\n');
|
||||
}
|
||||
return commandResult();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SessionGitHubPublicationResult } from "../../packages/gateway-protocol/src/schema/session-github-publication.js";
|
||||
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js";
|
||||
import {
|
||||
openOpenClawStateDatabase,
|
||||
runOpenClawStateWriteTransaction,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
prepareCurrentGitHubPublicationIdentity,
|
||||
resolveGitHubPublicationWorktreeOwner,
|
||||
} from "./github-publication-availability.js";
|
||||
import { createGitHubPublicationCoordinatorMethods } from "./github-publication-coordinator-methods.js";
|
||||
import {
|
||||
captureGitHubPublicationWorkspaceSnapshot,
|
||||
executeGitHubPublication,
|
||||
matchesGitHubPublicationIdentityRow,
|
||||
} from "./github-publication-executor.js";
|
||||
import { resolveGitHubPublicationFailure } from "./github-publication-failure.js";
|
||||
import {
|
||||
claimGitHubPublicationExecution as claimExecution,
|
||||
digestGitHubPublicationRequest as digestRequest,
|
||||
ensureGitHubPublicationStore as ensureSchema,
|
||||
githubPublicationDatabase as publicationDb,
|
||||
isGitHubPublicationExecutionOwner as ownsExecution,
|
||||
projectGitHubPublicationResult as publicationResult,
|
||||
type GitHubPublicationRow as PublicationRow,
|
||||
} from "./github-publication-store.js";
|
||||
import type {
|
||||
WorkerSessionPlacementStore,
|
||||
WorkerSessionTurnClaim,
|
||||
} from "./worker-environments/placement-store.js";
|
||||
|
||||
const activePublicationExecutions = new Map<string, Promise<SessionGitHubPublicationResult>>();
|
||||
|
||||
function sameWorktree(
|
||||
row: PublicationRow,
|
||||
worktree: ReturnType<typeof resolveGitHubPublicationWorktreeOwner>["worktree"],
|
||||
): boolean {
|
||||
return (
|
||||
row.worktree_id === worktree.id &&
|
||||
row.repository_fingerprint === worktree.repoFingerprint &&
|
||||
row.branch === worktree.branch
|
||||
);
|
||||
}
|
||||
|
||||
function sameClaim(row: PublicationRow, claim: WorkerSessionTurnClaim): boolean {
|
||||
return (
|
||||
row.claim_id === claim.claimId &&
|
||||
row.run_id === claim.runId &&
|
||||
row.placement_generation === claim.placementGeneration &&
|
||||
row.environment_id === (claim.owner.environmentId ?? null) &&
|
||||
row.owner_epoch === (claim.owner.ownerEpoch ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
function assertStoredClaim(
|
||||
db: Parameters<typeof getNodeSqliteKysely>[0],
|
||||
request: {
|
||||
claim: WorkerSessionTurnClaim;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
},
|
||||
): void {
|
||||
const row = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("worker_session_placements")
|
||||
.select([
|
||||
"agent_id",
|
||||
"session_key",
|
||||
"state",
|
||||
"environment_id",
|
||||
"active_owner_epoch",
|
||||
"turn_claim_owner",
|
||||
"turn_claim_id",
|
||||
"turn_claim_run_id",
|
||||
"turn_claim_generation",
|
||||
"turn_claim_owner_epoch",
|
||||
])
|
||||
.where("session_id", "=", request.claim.sessionId),
|
||||
).rows[0];
|
||||
const ownerMatches =
|
||||
request.claim.owner.kind === "worker"
|
||||
? row?.turn_claim_owner === "worker" &&
|
||||
row.environment_id === request.claim.owner.environmentId &&
|
||||
row.active_owner_epoch === request.claim.owner.ownerEpoch &&
|
||||
row.turn_claim_owner_epoch === request.claim.owner.ownerEpoch
|
||||
: row?.turn_claim_owner === "local";
|
||||
if (
|
||||
!row ||
|
||||
(row.state !== "active" && row.state !== "draining" && row.state !== "local") ||
|
||||
row.agent_id !== request.agentId ||
|
||||
row.session_key !== request.sessionKey ||
|
||||
row.turn_claim_id !== request.claim.claimId ||
|
||||
row.turn_claim_run_id !== request.claim.runId ||
|
||||
row.turn_claim_generation !== request.claim.placementGeneration ||
|
||||
!ownerMatches
|
||||
) {
|
||||
throw new Error("GitHub publication turn authority changed before recording.");
|
||||
}
|
||||
}
|
||||
|
||||
export type GitHubPublicationCoordinator = ReturnType<typeof createGitHubPublicationCoordinator>;
|
||||
|
||||
export function createGitHubPublicationCoordinator(params: {
|
||||
placements: WorkerSessionPlacementStore;
|
||||
}) {
|
||||
const instanceId = params.placements.workspaceResultInstanceId();
|
||||
|
||||
const readById = (requestId: string): PublicationRow | undefined => {
|
||||
ensureSchema();
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", requestId),
|
||||
).rows[0];
|
||||
};
|
||||
|
||||
const requestForClaim = async (request: {
|
||||
claim: WorkerSessionTurnClaim;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
idempotencyKey: string;
|
||||
title?: string;
|
||||
body?: string;
|
||||
assertCurrent?: () => void;
|
||||
}): Promise<SessionGitHubPublicationResult> => {
|
||||
ensureSchema();
|
||||
request.assertCurrent?.();
|
||||
if (!params.placements.validateTurnClaim(request.claim)) {
|
||||
throw new Error("GitHub publication lost the live session turn claim.");
|
||||
}
|
||||
const placement = params.placements.get(request.claim.sessionId);
|
||||
if (
|
||||
!placement ||
|
||||
placement.sessionKey !== request.sessionKey ||
|
||||
placement.agentId !== request.agentId
|
||||
) {
|
||||
throw new Error("GitHub publication session identity changed.");
|
||||
}
|
||||
resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId: request.claim.sessionId,
|
||||
sessionKey: request.sessionKey,
|
||||
agentId: request.agentId,
|
||||
});
|
||||
request.assertCurrent?.();
|
||||
const identity = await prepareCurrentGitHubPublicationIdentity(request.agentId);
|
||||
request.assertCurrent?.();
|
||||
if (!params.placements.validateTurnClaim(request.claim)) {
|
||||
throw new Error("GitHub publication lost the live session turn claim after verification.");
|
||||
}
|
||||
const { worktree } = resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId: request.claim.sessionId,
|
||||
sessionKey: request.sessionKey,
|
||||
agentId: request.agentId,
|
||||
});
|
||||
const requestDigest = digestRequest({
|
||||
sessionId: request.claim.sessionId,
|
||||
idempotencyKey: request.idempotencyKey,
|
||||
title: request.title,
|
||||
body: request.body,
|
||||
});
|
||||
const now = Date.now();
|
||||
const requestId = randomUUID();
|
||||
const row = runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
assertStoredClaim(db, request);
|
||||
const query = publicationDb(db);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.insertInto("github_publication_requests")
|
||||
.values({
|
||||
request_id: requestId,
|
||||
idempotency_key: request.idempotencyKey,
|
||||
request_digest: requestDigest,
|
||||
session_id: request.claim.sessionId,
|
||||
session_key: request.sessionKey,
|
||||
agent_id: request.agentId,
|
||||
worktree_id: worktree.id,
|
||||
repository_fingerprint: worktree.repoFingerprint,
|
||||
claim_id: request.claim.claimId,
|
||||
run_id: request.claim.runId,
|
||||
environment_id: request.claim.owner.environmentId ?? null,
|
||||
owner_epoch: request.claim.owner.ownerEpoch ?? null,
|
||||
placement_generation: request.claim.placementGeneration,
|
||||
identity_source: identity.source,
|
||||
identity_profile_id: identity.profileId ?? null,
|
||||
identity_account_id: identity.account.accountId,
|
||||
identity_login: identity.account.login,
|
||||
title: request.title ?? null,
|
||||
body: request.body ?? null,
|
||||
status: "requested",
|
||||
gateway_instance_id: null,
|
||||
repository: null,
|
||||
branch: worktree.branch,
|
||||
base_branch: null,
|
||||
source_head_commit: null,
|
||||
source_index_tree: null,
|
||||
workspace_tree: null,
|
||||
head_commit: null,
|
||||
pull_request_url: null,
|
||||
error_code: null,
|
||||
next_action: null,
|
||||
created_at_ms: now,
|
||||
updated_at_ms: now,
|
||||
reported_at_ms: null,
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.columns(["session_id", "idempotency_key"]).doNothing(),
|
||||
),
|
||||
);
|
||||
const stored = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", request.claim.sessionId)
|
||||
.where("idempotency_key", "=", request.idempotencyKey),
|
||||
).rows[0];
|
||||
if (
|
||||
!stored ||
|
||||
stored.request_digest !== requestDigest ||
|
||||
!sameClaim(stored, request.claim) ||
|
||||
!matchesGitHubPublicationIdentityRow(stored, identity) ||
|
||||
!sameWorktree(stored, worktree)
|
||||
) {
|
||||
throw new Error("GitHub publication idempotency key was reused.");
|
||||
}
|
||||
return stored;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.request" },
|
||||
);
|
||||
return publicationResult(row);
|
||||
};
|
||||
|
||||
const bindWorkspaceSnapshot = (input: {
|
||||
row: PublicationRow;
|
||||
sourceHeadCommit: string;
|
||||
sourceIndexTree: string;
|
||||
workspaceTree: string;
|
||||
}): PublicationRow =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const query = publicationDb(db);
|
||||
const updated = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.updateTable("github_publication_requests")
|
||||
.set({
|
||||
source_head_commit: input.sourceHeadCommit,
|
||||
source_index_tree: input.sourceIndexTree,
|
||||
workspace_tree: input.workspaceTree,
|
||||
updated_at_ms: Date.now(),
|
||||
})
|
||||
.where("request_id", "=", input.row.request_id)
|
||||
.where("status", "=", "publishing")
|
||||
.where("gateway_instance_id", "=", instanceId)
|
||||
.where("source_head_commit", "is", null)
|
||||
.where("source_index_tree", "is", null)
|
||||
.where("workspace_tree", "is", null),
|
||||
);
|
||||
if (updated.numAffectedRows !== 1n) {
|
||||
throw new Error("GitHub publication workspace snapshot changed before execution.");
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", input.row.request_id),
|
||||
).rows[0]!;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.bind-workspace" },
|
||||
);
|
||||
|
||||
const bindAcceptedClaimSnapshot = (input: {
|
||||
row: PublicationRow;
|
||||
claim: WorkerSessionTurnClaim;
|
||||
sourceHeadCommit: string;
|
||||
sourceIndexTree: string;
|
||||
workspaceTree: string;
|
||||
}): PublicationRow =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
assertStoredClaim(db, {
|
||||
claim: input.claim,
|
||||
sessionKey: input.row.session_key,
|
||||
agentId: input.row.agent_id,
|
||||
});
|
||||
const query = publicationDb(db);
|
||||
const current = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", input.row.request_id),
|
||||
).rows[0];
|
||||
if (
|
||||
!current ||
|
||||
current.claim_id !== input.claim.claimId ||
|
||||
current.run_id !== input.claim.runId ||
|
||||
(current.status !== "requested" && current.status !== "publishing")
|
||||
) {
|
||||
throw new Error("GitHub publication workspace snapshot owner changed.");
|
||||
}
|
||||
if (current.source_head_commit || current.source_index_tree || current.workspace_tree) {
|
||||
if (
|
||||
current.source_head_commit !== input.sourceHeadCommit ||
|
||||
current.source_index_tree !== input.sourceIndexTree ||
|
||||
current.workspace_tree !== input.workspaceTree
|
||||
) {
|
||||
throw new Error("GitHub publication accepted workspace snapshot changed.");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
const updated = executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.updateTable("github_publication_requests")
|
||||
.set({
|
||||
source_head_commit: input.sourceHeadCommit,
|
||||
source_index_tree: input.sourceIndexTree,
|
||||
workspace_tree: input.workspaceTree,
|
||||
updated_at_ms: Date.now(),
|
||||
})
|
||||
.where("request_id", "=", input.row.request_id)
|
||||
.where("source_head_commit", "is", null)
|
||||
.where("source_index_tree", "is", null)
|
||||
.where("workspace_tree", "is", null),
|
||||
);
|
||||
if (updated.numAffectedRows !== 1n) {
|
||||
throw new Error("GitHub publication accepted workspace snapshot changed.");
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
query
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", input.row.request_id),
|
||||
).rows[0]!;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.bind-accepted-workspace" },
|
||||
);
|
||||
|
||||
const updatePublishingFacts = (input: {
|
||||
row: PublicationRow;
|
||||
repository: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
sourceHeadCommit: string;
|
||||
workspaceTree: string;
|
||||
headCommit: string;
|
||||
}): PublicationRow =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const result = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.updateTable("github_publication_requests")
|
||||
.set({
|
||||
repository: input.repository,
|
||||
branch: input.branch,
|
||||
base_branch: input.baseBranch,
|
||||
source_head_commit: input.sourceHeadCommit,
|
||||
workspace_tree: input.workspaceTree,
|
||||
head_commit: input.headCommit,
|
||||
updated_at_ms: Date.now(),
|
||||
})
|
||||
.where("request_id", "=", input.row.request_id)
|
||||
.where("status", "=", "publishing")
|
||||
.where("gateway_instance_id", "=", instanceId),
|
||||
);
|
||||
if (result.numAffectedRows !== 1n) {
|
||||
throw new Error("GitHub publication state changed before execution.");
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", input.row.request_id),
|
||||
).rows[0]!;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.begin" },
|
||||
);
|
||||
|
||||
const complete = (row: PublicationRow, result: SessionGitHubPublicationResult): PublicationRow =>
|
||||
runOpenClawStateWriteTransaction(
|
||||
({ db }) => {
|
||||
const values =
|
||||
result.status === "published"
|
||||
? {
|
||||
status: "published",
|
||||
pull_request_url: result.url,
|
||||
repository: result.repository,
|
||||
branch: result.branch,
|
||||
head_commit: result.headCommit,
|
||||
error_code: null,
|
||||
next_action: null,
|
||||
}
|
||||
: result.status === "failed"
|
||||
? {
|
||||
status: "failed",
|
||||
pull_request_url: null,
|
||||
error_code: result.code,
|
||||
next_action: result.nextAction,
|
||||
}
|
||||
: undefined;
|
||||
if (!values) {
|
||||
throw new Error("GitHub publication terminal result is invalid.");
|
||||
}
|
||||
const updated = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.updateTable("github_publication_requests")
|
||||
.set({ ...values, updated_at_ms: Date.now() })
|
||||
.where("request_id", "=", row.request_id)
|
||||
.where("status", "=", "publishing")
|
||||
.where("gateway_instance_id", "=", instanceId),
|
||||
);
|
||||
if (updated.numAffectedRows !== 1n) {
|
||||
throw new Error("GitHub publication state changed before completion.");
|
||||
}
|
||||
return executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("request_id", "=", row.request_id),
|
||||
).rows[0]!;
|
||||
},
|
||||
undefined,
|
||||
{ operationLabel: "github-publication.complete" },
|
||||
);
|
||||
|
||||
const processRow = (
|
||||
initial: PublicationRow,
|
||||
validateAuthority: () => boolean,
|
||||
): Promise<SessionGitHubPublicationResult> => {
|
||||
if (initial.status === "published" || initial.status === "failed") {
|
||||
return Promise.resolve(publicationResult(initial));
|
||||
}
|
||||
const executionKey = `${instanceId}\0${initial.request_id}`;
|
||||
const current = activePublicationExecutions.get(executionKey);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
const claimed = claimExecution(initial.request_id, instanceId);
|
||||
if (claimed.status === "published" || claimed.status === "failed") {
|
||||
return Promise.resolve(publicationResult(claimed));
|
||||
}
|
||||
const operation = executeGitHubPublication({
|
||||
initial: claimed,
|
||||
validateAuthority: () => validateAuthority() && ownsExecution(claimed.request_id, instanceId),
|
||||
projectResult: publicationResult,
|
||||
bindWorkspaceSnapshot,
|
||||
updatePublishingFacts,
|
||||
complete,
|
||||
});
|
||||
activePublicationExecutions.set(executionKey, operation);
|
||||
const release = () => {
|
||||
if (activePublicationExecutions.get(executionKey) === operation) {
|
||||
activePublicationExecutions.delete(executionKey);
|
||||
}
|
||||
};
|
||||
void operation.then(release, release);
|
||||
return operation;
|
||||
};
|
||||
|
||||
const prepareClaimWorkspace = async (claim: WorkerSessionTurnClaim): Promise<void> => {
|
||||
ensureSchema();
|
||||
params.placements.closeWorkerTurnToolAdmission(claim);
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", claim.sessionId)
|
||||
.where("claim_id", "=", claim.claimId)
|
||||
.where("run_id", "=", claim.runId)
|
||||
.where("status", "in", ["requested", "publishing"])
|
||||
.orderBy("created_at_ms"),
|
||||
).rows;
|
||||
if (rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!params.placements.validateWorkspaceResultClaim(claim)) {
|
||||
throw new Error("GitHub publication lost its workspace result claim before snapshot.");
|
||||
}
|
||||
const first = rows[0]!;
|
||||
const { worktree } = resolveGitHubPublicationWorktreeOwner({
|
||||
sessionId: first.session_id,
|
||||
sessionKey: first.session_key,
|
||||
agentId: first.agent_id,
|
||||
expected: {
|
||||
worktreeId: first.worktree_id,
|
||||
repositoryFingerprint: first.repository_fingerprint,
|
||||
branch: first.branch,
|
||||
},
|
||||
});
|
||||
for (const row of rows) {
|
||||
if (!sameWorktree(row, worktree)) {
|
||||
throw new Error("GitHub publication worktree changed before accepted snapshot.");
|
||||
}
|
||||
}
|
||||
const bound = rows.find(
|
||||
(row) => row.source_head_commit && row.source_index_tree && row.workspace_tree,
|
||||
);
|
||||
if (bound) {
|
||||
for (const row of rows) {
|
||||
if (
|
||||
(row.source_head_commit || row.source_index_tree || row.workspace_tree) &&
|
||||
(row.source_head_commit !== bound.source_head_commit ||
|
||||
row.source_index_tree !== bound.source_index_tree ||
|
||||
row.workspace_tree !== bound.workspace_tree)
|
||||
) {
|
||||
throw new Error("GitHub publication accepted workspace snapshot changed.");
|
||||
}
|
||||
}
|
||||
if (
|
||||
rows.every((row) => row.source_head_commit && row.source_index_tree && row.workspace_tree)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const snapshot = await captureGitHubPublicationWorkspaceSnapshot({
|
||||
cwd: worktree.path,
|
||||
assertCurrent: () => {
|
||||
if (!params.placements.validateWorkspaceResultClaim(claim)) {
|
||||
throw new Error("GitHub publication lost its workspace result claim during snapshot.");
|
||||
}
|
||||
},
|
||||
});
|
||||
for (const row of rows) {
|
||||
bindAcceptedClaimSnapshot({ row, claim, ...snapshot });
|
||||
}
|
||||
};
|
||||
|
||||
const failClaimPreparation = (
|
||||
claim: WorkerSessionTurnClaim,
|
||||
error: unknown,
|
||||
): SessionGitHubPublicationResult[] => {
|
||||
ensureSchema();
|
||||
const db = openOpenClawStateDatabase().db;
|
||||
const rows = executeSqliteQuerySync(
|
||||
db,
|
||||
publicationDb(db)
|
||||
.selectFrom("github_publication_requests")
|
||||
.selectAll()
|
||||
.where("session_id", "=", claim.sessionId)
|
||||
.where("claim_id", "=", claim.claimId)
|
||||
.where("run_id", "=", claim.runId)
|
||||
.orderBy("created_at_ms"),
|
||||
).rows;
|
||||
const failure = resolveGitHubPublicationFailure(error);
|
||||
return rows.map((row) => {
|
||||
if (row.status === "published" || row.status === "failed") {
|
||||
return publicationResult(row);
|
||||
}
|
||||
const claimed = claimExecution(row.request_id, instanceId);
|
||||
return publicationResult(
|
||||
complete(claimed, {
|
||||
requestId: row.request_id,
|
||||
status: "failed",
|
||||
code: failure.code,
|
||||
message: "GitHub publication failed.",
|
||||
nextAction: failure.nextAction,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
requestForClaim,
|
||||
prepareClaimWorkspace,
|
||||
failClaimPreparation,
|
||||
...createGitHubPublicationCoordinatorMethods({
|
||||
placements: params.placements,
|
||||
instanceId,
|
||||
readById,
|
||||
requestForClaim,
|
||||
sameWorktree,
|
||||
processRow,
|
||||
failClaimPreparation,
|
||||
complete,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
||||
import { readNonBlankString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
type GitHubRepositoryRef = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
|
||||
type GitHubRepositoryTarget = {
|
||||
fork: boolean;
|
||||
push: GitHubRepositoryRef;
|
||||
pullRequest: GitHubRepositoryRef & { defaultBranch: string };
|
||||
};
|
||||
|
||||
export function resolveGitHubForkParent(value: unknown): GitHubRepositoryRef | undefined {
|
||||
if (!isRecord(value) || value.fork !== true || !isRecord(value.parent)) {
|
||||
return undefined;
|
||||
}
|
||||
const parentOwner = isRecord(value.parent.owner) ? value.parent.owner : undefined;
|
||||
const owner = readNonBlankString(parentOwner?.login)?.trim();
|
||||
const repo = readNonBlankString(value.parent.name)?.trim();
|
||||
return owner && repo ? { owner, repo } : undefined;
|
||||
}
|
||||
|
||||
/** Projects GitHub's repository response into the canonical push/head/base relationship. */
|
||||
export function resolveGitHubRepositoryTarget(
|
||||
value: unknown,
|
||||
push: GitHubRepositoryRef,
|
||||
): GitHubRepositoryTarget | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const defaultBranch = readNonBlankString(value.default_branch)?.trim();
|
||||
if (value.fork !== true) {
|
||||
return defaultBranch
|
||||
? { fork: false, push, pullRequest: { ...push, defaultBranch } }
|
||||
: undefined;
|
||||
}
|
||||
const parent = resolveGitHubForkParent(value);
|
||||
const parentRecord = isRecord(value.parent) ? value.parent : undefined;
|
||||
const parentDefaultBranch = readNonBlankString(parentRecord?.default_branch)?.trim();
|
||||
return parent && parentDefaultBranch
|
||||
? {
|
||||
fork: true,
|
||||
push,
|
||||
pullRequest: { ...parent, defaultBranch: parentDefaultBranch },
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"sessions.recover",
|
||||
"update.hold",
|
||||
"sessions.catalog.startTerminal",
|
||||
"sessions.github.publish",
|
||||
"worker.desktop.observe",
|
||||
"projects.list",
|
||||
"projects.register",
|
||||
|
||||
@@ -598,6 +598,13 @@ const CORE_GATEWAY_METHOD_SPECS = [
|
||||
"2026.8",
|
||||
{ controlPlaneWrite: true },
|
||||
],
|
||||
[
|
||||
"sessions.github.publish",
|
||||
"sessions-github",
|
||||
"operator.write",
|
||||
"2026.8",
|
||||
{ controlPlaneWrite: true },
|
||||
],
|
||||
["diagnostics.lanes", "diagnostics", "operator.read", "2026.8"],
|
||||
] as const satisfies readonly CoreGatewayMethodSpecRow[];
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
workerEnvironmentStartup,
|
||||
workerPlacementRuntime,
|
||||
workerPlacementControlAvailable,
|
||||
githubPublicationRuntime,
|
||||
githubPublicationService,
|
||||
terminalSessions,
|
||||
agentRunSeq,
|
||||
chatAbortControllers,
|
||||
@@ -172,6 +174,7 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
...(workerPlacementControlAvailable
|
||||
? { workerPlacementDispatchService: workerPlacementControlAvailable }
|
||||
: {}),
|
||||
...(githubPublicationService ? { githubPublicationService } : {}),
|
||||
validateAgentRuntimeApprovalAuthority,
|
||||
terminalSessions,
|
||||
agentRunSeq,
|
||||
@@ -217,6 +220,9 @@ export async function prepareGatewayKernelRequestRuntime(params: {
|
||||
flushPendingSessionsChangedEvents: shutdownRuntime.flushPendingSessionsChangedEvents,
|
||||
minimalTestGateway,
|
||||
logWarning: (message) => log.warn(message),
|
||||
...(!workerPlacementRuntime && githubPublicationRuntime
|
||||
? { reconcileGitHubPublications: githubPublicationRuntime.reconcilePublications }
|
||||
: {}),
|
||||
sidecars: runtimeState.gatewayLifetimeSidecars,
|
||||
});
|
||||
pluginGatewayContext.current = gatewayRequestContext;
|
||||
|
||||
@@ -68,6 +68,34 @@ describe("gateway lifetime sidecars", () => {
|
||||
expect(worker.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("owns standalone GitHub publication recovery when worker placement is unavailable", async () => {
|
||||
vi.useFakeTimers();
|
||||
const reconcileGitHubPublications = vi.fn(async () => {});
|
||||
const sidecars: GatewayPostReadySidecarHandle[] = [];
|
||||
const owner = createGatewaySidecarStopOwner({
|
||||
getRegistered: () => sidecars,
|
||||
setRegistered: (next) => sidecars.splice(0, sidecars.length, ...next),
|
||||
});
|
||||
|
||||
await attachInitialGatewayLifetimeSidecars({
|
||||
chatMetadataLifecycle: { attachContext: vi.fn(async () => {}) } as never,
|
||||
gatewayRequestContext: {} as never,
|
||||
flushPendingSessionsChangedEvents: vi.fn(),
|
||||
minimalTestGateway: false,
|
||||
logWarning: vi.fn(),
|
||||
reconcileGitHubPublications,
|
||||
sidecars,
|
||||
});
|
||||
vi.runAllTicks();
|
||||
expect(reconcileGitHubPublications).toHaveBeenCalledOnce();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(reconcileGitHubPublications).toHaveBeenCalledTimes(2);
|
||||
await owner.stop();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(reconcileGitHubPublications).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ minimalTestGateway: false, expectedHandoffRows: 0 },
|
||||
{ minimalTestGateway: true, expectedHandoffRows: 1 },
|
||||
|
||||
@@ -5,6 +5,38 @@ import type { GatewayPostReadySidecarHandle } from "./server-startup-post-attach
|
||||
|
||||
type GatewayChatMetadataLifecycle = Awaited<ReturnType<typeof createGatewayChatMetadataLifecycle>>;
|
||||
const SECRET_STORE_EXPIRY_INTERVAL_MS = 60_000;
|
||||
const GITHUB_PUBLICATION_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
function startGitHubPublicationMaintenance(
|
||||
reconcile: () => Promise<void>,
|
||||
logWarning: (message: string) => void,
|
||||
): GatewayPostReadySidecarHandle {
|
||||
let current: Promise<void> | undefined;
|
||||
let stopped = false;
|
||||
const run = () => {
|
||||
if (stopped || current) {
|
||||
return;
|
||||
}
|
||||
const operation = reconcile()
|
||||
.catch(() => logWarning("GitHub publication recovery failed; will retry."))
|
||||
.finally(() => {
|
||||
if (current === operation) {
|
||||
current = undefined;
|
||||
}
|
||||
});
|
||||
current = operation;
|
||||
};
|
||||
run();
|
||||
const interval = setInterval(run, GITHUB_PUBLICATION_RECONCILE_INTERVAL_MS);
|
||||
interval.unref?.();
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true;
|
||||
clearInterval(interval);
|
||||
await current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startSecretStoreExpiryMaintenance(
|
||||
logWarning: (message: string) => void,
|
||||
@@ -33,12 +65,18 @@ export async function attachInitialGatewayLifetimeSidecars(params: {
|
||||
flushPendingSessionsChangedEvents: (context?: object) => void;
|
||||
minimalTestGateway: boolean;
|
||||
logWarning: (message: string) => void;
|
||||
reconcileGitHubPublications?: () => Promise<void>;
|
||||
sidecars: GatewayPostReadySidecarHandle[];
|
||||
}): Promise<void> {
|
||||
await params.chatMetadataLifecycle.attachContext(params.gatewayRequestContext, params.sidecars);
|
||||
if (!params.minimalTestGateway) {
|
||||
params.sidecars.push(startSecretStoreExpiryMaintenance(params.logWarning));
|
||||
}
|
||||
if (params.reconcileGitHubPublications) {
|
||||
params.sidecars.push(
|
||||
startGitHubPublicationMaintenance(params.reconcileGitHubPublications, params.logWarning),
|
||||
);
|
||||
}
|
||||
params.sidecars.push({
|
||||
stop: () => {
|
||||
params.flushPendingSessionsChangedEvents(params.gatewayRequestContext);
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("listGatewayMethods", () => {
|
||||
});
|
||||
|
||||
it("appends new methods after model probing without shifting older method indices", () => {
|
||||
expect(listGatewayMethods().slice(-60)).toEqual([
|
||||
expect(listGatewayMethods().slice(-61)).toEqual([
|
||||
"models.probe",
|
||||
"migrations.memory.plan",
|
||||
"migrations.memory.apply",
|
||||
@@ -132,6 +132,7 @@ describe("listGatewayMethods", () => {
|
||||
"progressCard.put",
|
||||
"tools.github.status",
|
||||
"tools.github.configure",
|
||||
"sessions.github.publish",
|
||||
"diagnostics.lanes",
|
||||
]);
|
||||
const methods = listGatewayMethods();
|
||||
@@ -238,7 +239,7 @@ describe("listGatewayMethods", () => {
|
||||
"exec.approval.get",
|
||||
]);
|
||||
expect(methods).toContain("tts.speak");
|
||||
expect(coreMethods.slice(-67)).toEqual([
|
||||
expect(coreMethods.slice(-68)).toEqual([
|
||||
"sessions.catalog.continue",
|
||||
"sessions.catalog.archive",
|
||||
"approval.get",
|
||||
@@ -305,6 +306,7 @@ describe("listGatewayMethods", () => {
|
||||
"progressCard.put",
|
||||
"tools.github.status",
|
||||
"tools.github.configure",
|
||||
"sessions.github.publish",
|
||||
"diagnostics.lanes",
|
||||
]);
|
||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||
|
||||
@@ -395,7 +395,11 @@ describe("gateway method authorization", () => {
|
||||
);
|
||||
|
||||
const dispatchRequest = async (
|
||||
method: "sessions.create" | "sessions.fork" | "sessions.recover",
|
||||
method:
|
||||
| "sessions.create"
|
||||
| "sessions.fork"
|
||||
| "sessions.github.publish"
|
||||
| "sessions.recover",
|
||||
requestParams: Record<string, unknown>,
|
||||
profileId: string,
|
||||
) => {
|
||||
@@ -443,6 +447,10 @@ describe("gateway method authorization", () => {
|
||||
method: "sessions.fork" as const,
|
||||
params: { sessionKey, entryId: "user-entry" },
|
||||
},
|
||||
{
|
||||
method: "sessions.github.publish" as const,
|
||||
params: { sessionKey, idempotencyKey: "publication-1" },
|
||||
},
|
||||
{ method: "sessions.recover" as const, params: { key: sessionKey } },
|
||||
];
|
||||
for (const testCase of cases) {
|
||||
|
||||
@@ -143,6 +143,8 @@ const CORE_GATEWAY_HANDLER_MODULES = {
|
||||
send: () => import("./server-methods/send.js").then((module) => module.sendHandlers),
|
||||
"sessions-files": () =>
|
||||
import("./server-methods/sessions-files.js").then((module) => module.sessionsFilesHandlers),
|
||||
"sessions-github": () =>
|
||||
import("./server-methods/sessions-github.js").then((module) => module.sessionsGitHubHandlers),
|
||||
"sessions-diff": () =>
|
||||
import("./server-methods/sessions-diff.js").then((module) => module.sessionsDiffHandlers),
|
||||
"sessions-abort": () =>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { expectDefined } from "@openclaw/normalization-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { SessionMutationAuthorizationChangedError } from "../session-sharing.js";
|
||||
import { sessionsGitHubHandlers } from "./sessions-github.js";
|
||||
import type { SessionMutationAuthorization } from "./types.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
caller: vi.fn(),
|
||||
loadSession: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/tools/gateway-caller-context.js", () => ({
|
||||
getGatewayToolCallerIdentity: mocks.caller,
|
||||
}));
|
||||
vi.mock("../session-utils.js", () => ({
|
||||
loadGatewaySessionEntryReadOnly: mocks.loadSession,
|
||||
}));
|
||||
|
||||
async function invoke(
|
||||
params: Record<string, unknown>,
|
||||
sessionMutationAuthorization?: SessionMutationAuthorization,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionsGitHubHandlers["sessions.github.publish"],
|
||||
"sessions.github.publish handler",
|
||||
)({
|
||||
params,
|
||||
respond: respond as never,
|
||||
context: {
|
||||
githubPublicationService: { requestForSession: mocks.request },
|
||||
} as never,
|
||||
client: null,
|
||||
req: { type: "req", id: "req-publication", method: "sessions.github.publish" },
|
||||
isWebchatConnect: () => false,
|
||||
...(sessionMutationAuthorization ? { sessionMutationAuthorization } : {}),
|
||||
});
|
||||
return respond;
|
||||
}
|
||||
|
||||
describe("sessions.github.publish", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.caller.mockReturnValue({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:dashboard:task",
|
||||
operationalRunInstance: { runId: "run-1" },
|
||||
});
|
||||
mocks.loadSession.mockReturnValue({
|
||||
canonicalKey: "agent:main:dashboard:task",
|
||||
agentId: "main",
|
||||
entry: { sessionId: "session-1" },
|
||||
});
|
||||
mocks.request.mockResolvedValue({
|
||||
requestId: "publication-1",
|
||||
status: "requested",
|
||||
message: "Publication was accepted.",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses host-owned caller identity and forwards only bounded intent", async () => {
|
||||
const respond = await invoke({
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Publish the fix",
|
||||
});
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith({
|
||||
idempotencyKey: "tool-call-1",
|
||||
title: "Publish the fix",
|
||||
sessionKey: "agent:main:dashboard:task",
|
||||
agentId: "main",
|
||||
expectedRunId: "run-1",
|
||||
});
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
requestId: "publication-1",
|
||||
status: "requested",
|
||||
message: "Publication was accepted.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects caller-supplied repository authority at the protocol boundary", async () => {
|
||||
const respond = await invoke({
|
||||
idempotencyKey: "tool-call-1",
|
||||
repository: "openclaw/openclaw",
|
||||
branch: "main",
|
||||
token: "secret",
|
||||
});
|
||||
|
||||
expect(mocks.request).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes an operator-selected session before publication", async () => {
|
||||
mocks.caller.mockReturnValue(undefined);
|
||||
mocks.loadSession.mockReturnValue({
|
||||
canonicalKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
entry: { sessionId: "session-main" },
|
||||
});
|
||||
|
||||
await invoke({ sessionKey: "main", idempotencyKey: "operator-publication-1" });
|
||||
|
||||
expect(mocks.request).toHaveBeenCalledWith({
|
||||
sessionKey: "agent:main:main",
|
||||
idempotencyKey: "operator-publication-1",
|
||||
agentId: "main",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a publication whose session authorization changes while verification waits", async () => {
|
||||
let resolveRequest: ((value: unknown) => void) | undefined;
|
||||
mocks.request.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}),
|
||||
);
|
||||
let authorized = true;
|
||||
const changed = new SessionMutationAuthorizationChangedError(
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "session participation changed"),
|
||||
);
|
||||
const authorization: SessionMutationAuthorization = {
|
||||
assertCurrent: () => {
|
||||
if (!authorized) {
|
||||
throw changed;
|
||||
}
|
||||
},
|
||||
assertTargetCurrent: vi.fn(),
|
||||
};
|
||||
|
||||
const pending = invoke(
|
||||
{ sessionKey: "agent:main:dashboard:task", idempotencyKey: "publication-revoked" },
|
||||
authorization,
|
||||
);
|
||||
await vi.waitFor(() => expect(resolveRequest).toBeTypeOf("function"));
|
||||
authorized = false;
|
||||
resolveRequest?.({
|
||||
requestId: "publication-revoked",
|
||||
status: "requested",
|
||||
message: "Publication was accepted.",
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toBe(changed);
|
||||
expect(mocks.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ assertCurrent: authorization.assertCurrent }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
validateSessionGitHubPublishParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { getGatewayToolCallerIdentity } from "../../agents/tools/gateway-caller-context.js";
|
||||
import { SessionMutationAuthorizationChangedError } from "../session-sharing.js";
|
||||
import { loadGatewaySessionEntryReadOnly } from "../session-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
export const sessionsGitHubHandlers: GatewayRequestHandlers = {
|
||||
"sessions.github.publish": async ({ params, respond, context, sessionMutationAuthorization }) => {
|
||||
if (
|
||||
!assertValidParams(
|
||||
params,
|
||||
validateSessionGitHubPublishParams,
|
||||
"sessions.github.publish",
|
||||
respond,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const coordinator = context.githubPublicationService;
|
||||
if (!coordinator) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "GitHub publication is unavailable on this Gateway"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const caller = getGatewayToolCallerIdentity();
|
||||
const sessionKey = caller?.sessionKey ?? params.sessionKey;
|
||||
if (!sessionKey || (caller && params.sessionKey && params.sessionKey !== caller.sessionKey)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "GitHub publication session is invalid"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const loaded = loadGatewaySessionEntryReadOnly(
|
||||
sessionKey,
|
||||
caller?.agentId ? { agentId: caller.agentId } : undefined,
|
||||
);
|
||||
if (!loaded.entry?.sessionId) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "GitHub publication session was not found"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionMutationAuthorization?.assertCurrent();
|
||||
const result = await coordinator.requestForSession({
|
||||
...params,
|
||||
sessionKey: loaded.canonicalKey,
|
||||
agentId: caller?.agentId ?? loaded.agentId,
|
||||
...(caller?.operationalRunInstance?.runId
|
||||
? { expectedRunId: caller.operationalRunInstance.runId }
|
||||
: {}),
|
||||
...(sessionMutationAuthorization
|
||||
? { assertCurrent: sessionMutationAuthorization.assertCurrent }
|
||||
: {}),
|
||||
});
|
||||
sessionMutationAuthorization?.assertCurrent();
|
||||
respond(true, result);
|
||||
} catch (error) {
|
||||
if (error instanceof SessionMutationAuthorizationChangedError) {
|
||||
throw error;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
error instanceof Error ? error.message : "GitHub publication request failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -374,6 +374,7 @@ type GatewayResidentBridgeContext = {
|
||||
validateAgentRuntimeApprovalAuthority?: AgentRuntimeApprovalAuthorityValidator;
|
||||
/** One-way local-to-worker dispatch; absent when cloud workers are disabled. */
|
||||
workerPlacementDispatchService?: WorkerPlacementDispatchContract;
|
||||
githubPublicationService?: import("../github-publication.js").GitHubPublicationCoordinator;
|
||||
getRuntimeSnapshot: () => ChannelRuntimeSnapshot;
|
||||
getEventLoopHealth?: () => GatewayEventLoopHealth | undefined;
|
||||
getConfigReloaderHotReloadStatus?: () => GatewayHotReloadStatus | undefined;
|
||||
|
||||
@@ -95,10 +95,14 @@ describe("tools.github handlers", () => {
|
||||
|
||||
it("consumes a setup handoff, rotates config, and returns fresh status", async () => {
|
||||
secrets.consumeHandoff.mockReturnValue("temporary-test-token");
|
||||
github.install.mockImplementation(async (params: { commitConfig: () => Promise<void> }) => {
|
||||
await params.commitConfig();
|
||||
return status.account;
|
||||
});
|
||||
github.install.mockImplementation(
|
||||
async (params: {
|
||||
commitConfig: (account: { accountId: number; login: string }) => Promise<void>;
|
||||
}) => {
|
||||
await params.commitConfig({ accountId: 100, login: "managed-user" });
|
||||
return status.account;
|
||||
},
|
||||
);
|
||||
|
||||
const respond = await invoke("tools.github.configure", {
|
||||
scope: "agent",
|
||||
@@ -122,6 +126,38 @@ describe("tools.github handlers", () => {
|
||||
expect(JSON.stringify(respond.mock.calls)).not.toContain("temporary-test-token");
|
||||
});
|
||||
|
||||
it("defaults managed commit authorship to the verified GitHub user", async () => {
|
||||
secrets.consumeHandoff.mockReturnValue("temporary-test-token");
|
||||
github.install.mockImplementation(
|
||||
async (params: {
|
||||
commitConfig: (account: { accountId: number; login: string }) => Promise<void>;
|
||||
}) => {
|
||||
await params.commitConfig({ accountId: 123, login: "roboclaw-bot" });
|
||||
return status.account;
|
||||
},
|
||||
);
|
||||
|
||||
await invoke("tools.github.configure", {
|
||||
scope: "system",
|
||||
agentId: "main",
|
||||
mode: "managed",
|
||||
secretName: "github-setup-22222222222222222222222222222222",
|
||||
});
|
||||
|
||||
expect(github.updateConfig).toHaveBeenCalledWith({
|
||||
scope: "system",
|
||||
agentId: "main",
|
||||
identity: {
|
||||
profileId: "ghp_eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
gitAuthor: {
|
||||
name: "roboclaw-bot",
|
||||
email: "123+roboclaw-bot@users.noreply.github.com",
|
||||
},
|
||||
},
|
||||
expectedIdentity: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects blank author data without consuming the setup handoff", async () => {
|
||||
const respond = await invoke("tools.github.configure", {
|
||||
scope: "agent",
|
||||
|
||||
@@ -97,10 +97,6 @@ export const toolsGitHubHandlers: GatewayRequestHandlers = {
|
||||
throw new Error("temporary GitHub credential is unavailable");
|
||||
}
|
||||
const profileId = createManagedGitHubProfileId();
|
||||
const identity = {
|
||||
profileId,
|
||||
...(gitAuthor ? { gitAuthor } : {}),
|
||||
};
|
||||
const profileDir = resolveManagedGitHubProfileDir({
|
||||
agentId: resolved.agentId,
|
||||
scope: params.scope,
|
||||
@@ -110,7 +106,14 @@ export const toolsGitHubHandlers: GatewayRequestHandlers = {
|
||||
await installManagedGitHubProfile({
|
||||
profileDir,
|
||||
token,
|
||||
commitConfig: async () => {
|
||||
commitConfig: async (account) => {
|
||||
const identity = {
|
||||
profileId,
|
||||
gitAuthor: gitAuthor ?? {
|
||||
name: account.login,
|
||||
email: `${account.accountId}+${account.login}@users.noreply.github.com`,
|
||||
},
|
||||
};
|
||||
nextConfig = await updateGitHubToolIdentityConfig({
|
||||
scope: params.scope,
|
||||
agentId: resolved.agentId,
|
||||
|
||||
@@ -83,6 +83,7 @@ type GatewayRequestContextParams = {
|
||||
workerSessionPlacementService?: GatewayRequestContext["workerSessionPlacementService"];
|
||||
workerPlacementDiskSpaceReader?: GatewayRequestContext["workerPlacementDiskSpaceReader"];
|
||||
workerPlacementDispatchService?: GatewayRequestContext["workerPlacementDispatchService"];
|
||||
githubPublicationService?: GatewayRequestContext["githubPublicationService"];
|
||||
validateAgentRuntimeApprovalAuthority: GatewayRequestContext["validateAgentRuntimeApprovalAuthority"];
|
||||
terminalSessions?: GatewayRequestContext["terminalSessions"];
|
||||
agentRunSeq: GatewayRequestContext["agentRunSeq"];
|
||||
@@ -384,6 +385,9 @@ export function createGatewayRequestContext(
|
||||
...(params.workerPlacementDispatchService
|
||||
? { workerPlacementDispatchService: params.workerPlacementDispatchService }
|
||||
: {}),
|
||||
...(params.githubPublicationService
|
||||
? { githubPublicationService: params.githubPublicationService }
|
||||
: {}),
|
||||
terminalSessions: params.terminalSessions,
|
||||
agentRunSeq: params.agentRunSeq,
|
||||
chatAbortControllers: params.chatAbortControllers,
|
||||
|
||||
@@ -165,6 +165,7 @@ export async function prepareGatewayKernelState(params: {
|
||||
nodeWorkerGatewayNamespace,
|
||||
bindDeviceNodeControl,
|
||||
bindNodeWorkspaceBindingResolver,
|
||||
bindGitHubPublication,
|
||||
handleNodeWorkerBundleTransferRequest,
|
||||
handleNodeWorkspaceTransferRequest,
|
||||
} = workerEnvironmentRuntime;
|
||||
@@ -174,18 +175,34 @@ export async function prepareGatewayKernelState(params: {
|
||||
throw new Error("Worker dispatch authority revocation is not ready");
|
||||
},
|
||||
};
|
||||
const workerPlacementModule = workerEnvironmentStartup
|
||||
? await startupTrace.measure(
|
||||
"worker-environments.placement-module",
|
||||
loadWorkerPlacementStartupModule,
|
||||
)
|
||||
: undefined;
|
||||
const githubPublicationRuntime =
|
||||
workerEnvironmentStartup && workerPlacementModule
|
||||
? workerPlacementModule.createGatewayGitHubPublicationRuntime({
|
||||
placements: workerEnvironmentStartup.placementStore,
|
||||
warn: (message) => log.warn(message),
|
||||
})
|
||||
: undefined;
|
||||
const workerPlacementRuntime =
|
||||
workerEnvironmentService && workerEnvironmentStartup && nodeWorkerGatewayNamespace
|
||||
? await startupTrace.measure("worker-environments.placement-runtime", async () => {
|
||||
const placementModule = await loadWorkerPlacementStartupModule();
|
||||
return placementModule.createGatewayWorkerPlacementRuntime({
|
||||
workerEnvironmentService &&
|
||||
workerEnvironmentStartup &&
|
||||
nodeWorkerGatewayNamespace &&
|
||||
workerPlacementModule
|
||||
? await startupTrace.measure("worker-environments.placement-runtime", async () =>
|
||||
workerPlacementModule.createGatewayWorkerPlacementRuntime({
|
||||
placements: workerEnvironmentStartup.placementStore,
|
||||
environments: workerEnvironmentService,
|
||||
gatewayNamespace: nodeWorkerGatewayNamespace,
|
||||
revokeSessionAuthority: (request) => workerDispatchAuthority.revoke(request),
|
||||
warn: (message) => log.warn(message),
|
||||
});
|
||||
})
|
||||
...(githubPublicationRuntime ? { githubPublicationRuntime } : {}),
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
if (workerPlacementRuntime) {
|
||||
bindNodeWorkspaceBindingResolver?.(workerPlacementRuntime.resolveNodeWorkspaceBinding);
|
||||
@@ -193,6 +210,9 @@ export async function prepareGatewayKernelState(params: {
|
||||
workerPlacementRuntime.dispatchService.dispatch,
|
||||
);
|
||||
}
|
||||
if (githubPublicationRuntime) {
|
||||
bindGitHubPublication?.(githubPublicationRuntime.coordinator);
|
||||
}
|
||||
const bindDeviceNodeRuntime = bindDeviceNodeControl
|
||||
? (transport: Parameters<NonNullable<typeof bindDeviceNodeControl>>[0]) => {
|
||||
bindDeviceNodeControl(transport);
|
||||
@@ -495,6 +515,8 @@ export async function prepareGatewayKernelState(params: {
|
||||
bindDeviceNodeControl: bindDeviceNodeRuntime,
|
||||
workerDispatchAuthority,
|
||||
workerPlacementRuntime,
|
||||
githubPublicationRuntime,
|
||||
githubPublicationService: githubPublicationRuntime?.coordinator,
|
||||
workerPlacementControlAvailable,
|
||||
workerPlacementDispatchAvailable,
|
||||
workerDesktopObserveAvailable,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "../secrets/runtime-state.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import type { DesktopSessionRegistry } from "./desktop/session-registry.js";
|
||||
import type { GitHubPublicationCoordinator } from "./github-publication.js";
|
||||
import type { NodeWorkerSupervisorTransport } from "./node-registry-private.js";
|
||||
import type { WorkerBundleProducer, WorkerNpmArtifact } from "./worker-environments/bundle.js";
|
||||
import {
|
||||
@@ -53,6 +54,7 @@ export type GatewayWorkerEnvironmentRuntime = {
|
||||
workerTunnelManager?: WorkerTunnelManager;
|
||||
nodeWorkerGatewayNamespace?: string;
|
||||
bindWorkerSessionDispatch?: (dispatch: WorkerPlacementDispatchContract["dispatch"]) => void;
|
||||
bindGitHubPublication?: (coordinator: GitHubPublicationCoordinator) => void;
|
||||
bindDeviceNodeControl?: (transport: NodeWorkerSupervisorTransport) => void;
|
||||
bindNodeWorkspaceBindingResolver?: (resolver: NodeWorkerWorkspaceBindingResolver) => void;
|
||||
handleNodeWorkerBundleTransferRequest?: NodeWorkerBundleTransferHttpCallback;
|
||||
@@ -241,6 +243,11 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
let dispatchChild: WorkerPlacementDispatchContract["dispatch"] = async () => {
|
||||
throw new Error("Worker session dispatch is unavailable");
|
||||
};
|
||||
let githubPublication: Pick<GitHubPublicationCoordinator, "requestForClaim"> = {
|
||||
requestForClaim: async () => {
|
||||
throw new Error("GitHub publication is unavailable");
|
||||
},
|
||||
};
|
||||
const workerEnvironmentServiceBase = createWorkerEnvironmentService({
|
||||
store: params.startup.store,
|
||||
getConfig: getRuntimeConfig,
|
||||
@@ -335,6 +342,9 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
placements: params.startup.placementStore,
|
||||
environments: workerEnvironmentService,
|
||||
dispatchChild: (request) => dispatchChild(request),
|
||||
githubPublication: {
|
||||
requestForClaim: (request) => githubPublication.requestForClaim(request),
|
||||
},
|
||||
});
|
||||
return {
|
||||
workerEnvironmentService,
|
||||
@@ -344,6 +354,9 @@ export async function createGatewayWorkerEnvironmentRuntime(params: {
|
||||
bindWorkerSessionDispatch: (dispatch) => {
|
||||
dispatchChild = dispatch;
|
||||
},
|
||||
bindGitHubPublication: (coordinator) => {
|
||||
githubPublication = coordinator;
|
||||
},
|
||||
bindDeviceNodeControl: deviceRuntime.bindNodeTransport,
|
||||
bindNodeWorkspaceBindingResolver: (resolver) =>
|
||||
nodeWorkerTunnelManager.bindWorkspaceBindingResolver(resolver),
|
||||
|
||||
@@ -101,6 +101,7 @@ describe("worker placement startup health lifetime", () => {
|
||||
const warn = vi.fn();
|
||||
const runtime = createGatewayWorkerPlacementRuntime({
|
||||
placements: {
|
||||
workspaceResultInstanceId: () => "gateway-test",
|
||||
get: () => undefined,
|
||||
list: () => [],
|
||||
retireSessionPlacement: vi.fn(),
|
||||
@@ -191,6 +192,7 @@ describe("worker placement startup health lifetime", () => {
|
||||
};
|
||||
const runtime = createGatewayWorkerPlacementRuntime({
|
||||
placements: {
|
||||
workspaceResultInstanceId: () => "gateway-test",
|
||||
get: () => placement,
|
||||
list: () => [placement],
|
||||
retireSessionPlacement: vi.fn(),
|
||||
@@ -257,6 +259,7 @@ describe("worker placement startup health lifetime", () => {
|
||||
};
|
||||
const runtime = createGatewayWorkerPlacementRuntime({
|
||||
placements: {
|
||||
workspaceResultInstanceId: () => "gateway-test",
|
||||
get: () => undefined,
|
||||
list: () => [],
|
||||
retireSessionPlacement: vi.fn(),
|
||||
@@ -303,7 +306,7 @@ describe("worker placement move destination", () => {
|
||||
reconcileActive: vi.fn(),
|
||||
});
|
||||
createGatewayWorkerPlacementRuntime({
|
||||
placements: {} as never,
|
||||
placements: { workspaceResultInstanceId: () => "gateway-test" } as never,
|
||||
environments: {} as never,
|
||||
gatewayNamespace: "gateway-test",
|
||||
revokeSessionAuthority: vi.fn(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveConfiguredGitHubToolIdentity } from "../agents/github-tool-identity.js";
|
||||
import { installSessionPlacementAdmissionProvider } from "../agents/session-placement-admission.js";
|
||||
import { clearSessionQueues } from "../auto-reply/reply/queue/cleanup.js";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from "../sessions/session-lifecycle-admission.js";
|
||||
import { onSessionIdentityMutation } from "../sessions/session-lifecycle-events.js";
|
||||
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
|
||||
import { createGitHubPublicationRuntime } from "./github-publication-runtime.js";
|
||||
import type { NodeWorkerSupervisorTransport } from "./node-registry-private.js";
|
||||
import { createGatewayWorkerPlacementReclaimBarriers } from "./server-worker-placement-reclaim.js";
|
||||
import { createWorkerPlacementSessionEvidenceResolver } from "./server-worker-placement-session-evidence.js";
|
||||
@@ -71,10 +73,32 @@ export type GatewayWorkerPlacementRuntimeParams = {
|
||||
warn: (message: string) => void;
|
||||
};
|
||||
|
||||
export function createGatewayGitHubPublicationRuntime(params: {
|
||||
placements: WorkerSessionPlacementStore;
|
||||
warn: (message: string) => void;
|
||||
}) {
|
||||
return createGitHubPublicationRuntime({
|
||||
placements: params.placements,
|
||||
loadSessionRuntime: loadWorkerPlacementSessionRuntimeModule,
|
||||
warn: params.warn,
|
||||
});
|
||||
}
|
||||
|
||||
export type GatewayWorkerPlacementRuntime = ReturnType<typeof createGatewayWorkerPlacementRuntime>;
|
||||
|
||||
export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlacementRuntimeParams) {
|
||||
export function createGatewayWorkerPlacementRuntime(
|
||||
params: GatewayWorkerPlacementRuntimeParams & {
|
||||
githubPublicationRuntime?: ReturnType<typeof createGitHubPublicationRuntime>;
|
||||
},
|
||||
) {
|
||||
const workspaceOperations = createWorkerWorkspaceOperationCoordinator();
|
||||
const {
|
||||
coordinator: githubPublication,
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
reconcilePublications,
|
||||
} = params.githubPublicationRuntime ??
|
||||
createGatewayGitHubPublicationRuntime({ placements: params.placements, warn: params.warn });
|
||||
const diskSpace = createWorkerPlacementDiskSpaceMonitor({
|
||||
placements: params.placements,
|
||||
environments: params.environments,
|
||||
@@ -416,6 +440,21 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
},
|
||||
resolveWorkspacePath,
|
||||
workspaceOperations,
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
resolveGitAuthor: (agentId) =>
|
||||
(
|
||||
resolveConfiguredGitHubToolIdentity({
|
||||
config: getRuntimeConfig(),
|
||||
agentId,
|
||||
scope: "agent",
|
||||
}) ??
|
||||
resolveConfiguredGitHubToolIdentity({
|
||||
config: getRuntimeConfig(),
|
||||
agentId,
|
||||
scope: "system",
|
||||
})
|
||||
)?.gitAuthor,
|
||||
}),
|
||||
);
|
||||
const sessionRetirement = createPlacementSessionRetirement({
|
||||
@@ -436,6 +475,8 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
dispatch: dispatchService.dispatch,
|
||||
}),
|
||||
workspaceOperations,
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
});
|
||||
const recoverPendingWorkspaceReconciliations = async (): Promise<void> => {
|
||||
const orphanedJournals = params.placements.pruneOrphanedWorkspaceReconciliations({
|
||||
@@ -533,6 +574,7 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
(async () => {
|
||||
await sessionRetirement.reconcile();
|
||||
await dispatchService.reconcileActive();
|
||||
await reconcilePublications();
|
||||
void nodeWorkspaceRetention.schedule();
|
||||
})(),
|
||||
"Worker placement reconcile sweep failed",
|
||||
@@ -619,6 +661,7 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
const startupReconcile = (async () => {
|
||||
await dispatchService.reconcile();
|
||||
await sessionRetirement.reconcile();
|
||||
await reconcilePublications();
|
||||
})();
|
||||
placementReconcile.current = startupReconcile;
|
||||
try {
|
||||
@@ -663,6 +706,7 @@ export function createGatewayWorkerPlacementRuntime(params: GatewayWorkerPlaceme
|
||||
admissionProvider,
|
||||
diskSpace,
|
||||
placements: params.placements,
|
||||
githubPublication,
|
||||
resolveNodeWorkspaceBinding,
|
||||
bindNodeWorkerSupervisorTransport: (transport: NodeWorkerSupervisorTransport) =>
|
||||
nodeWorkspaceRetention.bindTransport(transport),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type WorkerAdmissionFailureReason,
|
||||
type WorkerConnectParams,
|
||||
type WorkerLiveEventErrorDetails,
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_SESSION_TOOLS_PROTOCOL_FEATURE,
|
||||
type WorkerSessionToolResult,
|
||||
@@ -41,6 +42,7 @@ const HANDSHAKE = {
|
||||
WORKER_TRANSCRIPT_COMMIT_PROTOCOL_FEATURE,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_SESSION_TOOLS_PROTOCOL_FEATURE,
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
WORKER_INFERENCE_PROTOCOL_FEATURE,
|
||||
],
|
||||
};
|
||||
@@ -130,6 +132,30 @@ const INFERENCE_EVENT: WorkerInferenceEventFrame = {
|
||||
event: { type: "text_delta", contentIndex: 0, delta: "x" },
|
||||
},
|
||||
};
|
||||
const SESSION_TOOL_CASES = [
|
||||
{
|
||||
name: "spawn",
|
||||
method: "worker.sessions.spawn",
|
||||
toolName: "sessions_spawn",
|
||||
request: { toolCallId: "call-spawn", task: "run the child" },
|
||||
},
|
||||
{
|
||||
name: "send",
|
||||
method: "worker.sessions.send",
|
||||
toolName: "sessions_send",
|
||||
request: {
|
||||
toolCallId: "call-send",
|
||||
sessionKey: "agent:main:dashboard:child",
|
||||
message: "status",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
method: "worker.github.publish",
|
||||
toolName: "github_publish",
|
||||
request: { toolCallId: "call-publish", title: "Publish the fix" },
|
||||
},
|
||||
] as const;
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
function waitForWorkerProtocol(assertion: () => void) {
|
||||
@@ -468,64 +494,126 @@ describe("dedicated worker websocket protocol", () => {
|
||||
expect(harness.service.cancelInference).toHaveBeenCalledWith(ATTACHED_IDENTITY, INFERENCE_IDS);
|
||||
});
|
||||
|
||||
it("keeps heartbeats flowing while a session operation is pending", async () => {
|
||||
const operation = createDeferredCore<WorkerSessionToolResult>();
|
||||
const harness = attachHarness({
|
||||
identity: ATTACHED_IDENTITY,
|
||||
onSessionTool: () => operation.promise,
|
||||
});
|
||||
it.each(SESSION_TOOL_CASES)(
|
||||
"keeps heartbeats flowing while $name is pending",
|
||||
async (testCase) => {
|
||||
const operation = createDeferredCore<WorkerSessionToolResult>();
|
||||
const harness = attachHarness({
|
||||
identity: ATTACHED_IDENTITY,
|
||||
onSessionTool: () => operation.promise,
|
||||
});
|
||||
await admit(harness);
|
||||
harness.sendRequest(testCase.method, testCase.request, `${testCase.name}-1`);
|
||||
await waitForWorkerProtocol(() =>
|
||||
expect(harness.service.executeSessionTool).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
harness.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "busy" }, "heartbeat-1");
|
||||
await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2));
|
||||
expect(harness.responses[1]).toMatchObject({
|
||||
id: "heartbeat-1",
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
});
|
||||
|
||||
operation.resolve({ resultJson: JSON.stringify({ content: [] }) });
|
||||
await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(3));
|
||||
expect(harness.responses[2]).toMatchObject({ id: `${testCase.name}-1`, ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SESSION_TOOL_CASES)(
|
||||
"rejects an in-flight duplicate $name request id",
|
||||
async (testCase) => {
|
||||
const operation = createDeferredCore<WorkerSessionToolResult>();
|
||||
const harness = attachHarness({
|
||||
identity: ATTACHED_IDENTITY,
|
||||
onSessionTool: () => operation.promise,
|
||||
});
|
||||
await admit(harness);
|
||||
harness.sendRequest(testCase.method, testCase.request, "duplicate-session-operation");
|
||||
await waitForWorkerProtocol(() =>
|
||||
expect(harness.service.executeSessionTool).toHaveBeenCalledOnce(),
|
||||
);
|
||||
|
||||
harness.sendRequest(testCase.method, testCase.request, "duplicate-session-operation");
|
||||
await waitForWorkerProtocol(() =>
|
||||
expect(harness.close).toHaveBeenCalledWith(1008, "invalid-frame"),
|
||||
);
|
||||
expect(harness.service.executeSessionTool).toHaveBeenCalledOnce();
|
||||
operation.resolve({ resultJson: JSON.stringify({ content: [] }) });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SESSION_TOOL_CASES)(
|
||||
"continues durable $name work but suppresses its response after cleanup",
|
||||
async (testCase) => {
|
||||
let operationStarted = false;
|
||||
let operationSignal: AbortSignal | undefined;
|
||||
const operation = createDeferredCore<WorkerSessionToolResult>();
|
||||
const harness = attachHarness({
|
||||
identity: ATTACHED_IDENTITY,
|
||||
onSessionTool: (signal) => {
|
||||
operationStarted = true;
|
||||
operationSignal = signal;
|
||||
return operation.promise;
|
||||
},
|
||||
});
|
||||
await admit(harness);
|
||||
harness.sendRequest(testCase.method, testCase.request, `${testCase.name}-1`);
|
||||
await waitForWorkerProtocol(() => expect(operationStarted).toBe(true));
|
||||
|
||||
harness.cleanup();
|
||||
expect(operationSignal).toBeUndefined();
|
||||
operation.resolve({ resultJson: JSON.stringify({ content: [] }) });
|
||||
await Promise.resolve();
|
||||
expect(harness.responses).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(SESSION_TOOL_CASES)("routes and frames $name responses", async (testCase) => {
|
||||
const harness = attachHarness({ identity: ATTACHED_IDENTITY });
|
||||
await admit(harness);
|
||||
harness.sendRequest(
|
||||
"worker.sessions.spawn",
|
||||
{ toolCallId: "call-spawn", task: "run the child" },
|
||||
"spawn-1",
|
||||
);
|
||||
await waitForWorkerProtocol(() =>
|
||||
expect(harness.service.executeSessionTool).toHaveBeenCalledOnce(),
|
||||
);
|
||||
harness.sendRequest(testCase.method, testCase.request, `${testCase.name}-route`);
|
||||
|
||||
harness.sendRequest("worker.heartbeat", { sentAtMs: 1, status: "busy" }, "heartbeat-1");
|
||||
await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(2));
|
||||
expect(harness.service.executeSessionTool).toHaveBeenCalledWith(
|
||||
ATTACHED_IDENTITY,
|
||||
testCase.toolName,
|
||||
testCase.request,
|
||||
undefined,
|
||||
);
|
||||
expect(harness.responses[1]).toMatchObject({
|
||||
id: "heartbeat-1",
|
||||
id: `${testCase.name}-route`,
|
||||
ok: true,
|
||||
payload: { status: "ok" },
|
||||
payload: { resultJson: expect.any(String) },
|
||||
});
|
||||
expect(harness.setLastFrameMeta).toHaveBeenLastCalledWith({
|
||||
type: "req",
|
||||
method: testCase.method,
|
||||
});
|
||||
|
||||
operation.resolve({ resultJson: JSON.stringify({ content: [] }) });
|
||||
await waitForWorkerProtocol(() => expect(harness.responses).toHaveLength(3));
|
||||
expect(harness.responses[2]).toMatchObject({ id: "spawn-1", ok: true });
|
||||
});
|
||||
|
||||
it("continues durable session work but suppresses its response after connection cleanup", async () => {
|
||||
let operationStarted = false;
|
||||
let operationSignal: AbortSignal | undefined;
|
||||
const operation = createDeferredCore<WorkerSessionToolResult>();
|
||||
it.each(SESSION_TOOL_CASES)("feature-gates $name independently", async (testCase) => {
|
||||
const requiredFeature =
|
||||
testCase.toolName === "github_publish"
|
||||
? WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE
|
||||
: WORKER_SESSION_TOOLS_PROTOCOL_FEATURE;
|
||||
const harness = attachHarness({
|
||||
identity: ATTACHED_IDENTITY,
|
||||
onSessionTool: (signal) => {
|
||||
operationStarted = true;
|
||||
operationSignal = signal;
|
||||
return operation.promise;
|
||||
identity: {
|
||||
...ATTACHED_IDENTITY,
|
||||
protocolFeatures: ATTACHED_IDENTITY.protocolFeatures.filter(
|
||||
(feature) => feature !== requiredFeature,
|
||||
),
|
||||
},
|
||||
});
|
||||
await admit(harness);
|
||||
harness.sendRequest(
|
||||
"worker.sessions.send",
|
||||
{
|
||||
toolCallId: "call-send",
|
||||
sessionKey: "agent:main:dashboard:child",
|
||||
message: "status",
|
||||
},
|
||||
"send-1",
|
||||
);
|
||||
await waitForWorkerProtocol(() => expect(operationStarted).toBe(true));
|
||||
harness.sendRequest(testCase.method, testCase.request);
|
||||
|
||||
harness.cleanup();
|
||||
expect(operationSignal).toBeUndefined();
|
||||
operation.resolve({ resultJson: JSON.stringify({ content: [] }) });
|
||||
await Promise.resolve();
|
||||
expect(harness.responses).toHaveLength(1);
|
||||
await waitForWorkerProtocol(() =>
|
||||
expect(harness.close).toHaveBeenCalledWith(1008, "method-not-allowed"),
|
||||
);
|
||||
expect(harness.service.executeSessionTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches semantic transcript commits on the closed worker allowlist", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
type RequestFrame,
|
||||
type WorkerConnectParams,
|
||||
type WorkerGitHubPublishParams,
|
||||
type WorkerErrorShape,
|
||||
type WorkerHeartbeatResult,
|
||||
type WorkerLiveEventErrorDetails,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type WorkerTranscriptCommitParams,
|
||||
type WorkerTranscriptCommitResult,
|
||||
WORKER_LIVE_EVENT_PROTOCOL_FEATURE,
|
||||
WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE,
|
||||
WORKER_SESSION_TOOLS_PROTOCOL_FEATURE,
|
||||
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
|
||||
WORKER_PROTOCOL_MAX_METHOD_LENGTH,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
validateWorkerConnectRequestFrame,
|
||||
validateWorkerHeartbeatParams,
|
||||
validateWorkerLiveEventParams,
|
||||
validateWorkerGitHubPublishParams,
|
||||
validateWorkerSessionsSendParams,
|
||||
validateWorkerSessionsSpawnParams,
|
||||
validateWorkerTranscriptCommitParams,
|
||||
@@ -97,8 +100,8 @@ export type WorkerConnectionService = {
|
||||
) => WorkerProtocolCloseReason | null;
|
||||
executeSessionTool?: (
|
||||
identity: WorkerConnectionIdentity,
|
||||
toolName: "sessions_spawn" | "sessions_send",
|
||||
request: WorkerSessionsSpawnParams | WorkerSessionsSendParams,
|
||||
toolName: "sessions_spawn" | "sessions_send" | "github_publish",
|
||||
request: WorkerSessionsSpawnParams | WorkerSessionsSendParams | WorkerGitHubPublishParams,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<WorkerServiceResult<WorkerSessionToolResult, { reason: WorkerProtocolCloseReason }>>;
|
||||
};
|
||||
@@ -295,9 +298,14 @@ async function dispatchWorkerRequest(params: {
|
||||
}
|
||||
if (
|
||||
params.request.method === WORKER_PROTOCOL_METHODS[3] ||
|
||||
params.request.method === WORKER_PROTOCOL_METHODS[4]
|
||||
params.request.method === WORKER_PROTOCOL_METHODS[4] ||
|
||||
params.request.method === WORKER_PROTOCOL_METHODS[5]
|
||||
) {
|
||||
if (!params.identity.protocolFeatures.includes(WORKER_SESSION_TOOLS_PROTOCOL_FEATURE)) {
|
||||
const isGitHubPublish = params.request.method === WORKER_PROTOCOL_METHODS[5];
|
||||
const requiredFeature = isGitHubPublish
|
||||
? WORKER_GITHUB_PUBLICATION_PROTOCOL_FEATURE
|
||||
: WORKER_SESSION_TOOLS_PROTOCOL_FEATURE;
|
||||
if (!params.identity.protocolFeatures.includes(requiredFeature)) {
|
||||
rejectWorkerRequest({ ...params, reason: "method-not-allowed" });
|
||||
return;
|
||||
}
|
||||
@@ -308,15 +316,20 @@ async function dispatchWorkerRequest(params: {
|
||||
const isSpawn = params.request.method === WORKER_PROTOCOL_METHODS[3];
|
||||
const requestValid = isSpawn
|
||||
? validateWorkerSessionsSpawnParams(params.request.params)
|
||||
: validateWorkerSessionsSendParams(params.request.params);
|
||||
: isGitHubPublish
|
||||
? validateWorkerGitHubPublishParams(params.request.params)
|
||||
: validateWorkerSessionsSendParams(params.request.params);
|
||||
if (!requestValid) {
|
||||
params.respond(false, undefined, workerProtocolError("invalid-frame"));
|
||||
return;
|
||||
}
|
||||
const outcome = await service.executeSessionTool(
|
||||
params.identity,
|
||||
isSpawn ? "sessions_spawn" : "sessions_send",
|
||||
params.request.params as WorkerSessionsSpawnParams | WorkerSessionsSendParams,
|
||||
isSpawn ? "sessions_spawn" : isGitHubPublish ? "github_publish" : "sessions_send",
|
||||
params.request.params as
|
||||
| WorkerSessionsSpawnParams
|
||||
| WorkerSessionsSendParams
|
||||
| WorkerGitHubPublishParams,
|
||||
params.signal,
|
||||
);
|
||||
if (outcome.ok) {
|
||||
@@ -562,6 +575,7 @@ export function attachWorkerWsMessageHandler(params: WorkerWsMessageHandlerParam
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[2] ||
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[3] ||
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[4] ||
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[5] ||
|
||||
parsed.method === WORKER_INFERENCE_METHODS[0] ||
|
||||
parsed.method === WORKER_INFERENCE_METHODS[1]
|
||||
) {
|
||||
@@ -594,7 +608,9 @@ export function attachWorkerWsMessageHandler(params: WorkerWsMessageHandlerParam
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
const isLongSessionOperation =
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[3] || parsed.method === WORKER_PROTOCOL_METHODS[4];
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[3] ||
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[4] ||
|
||||
parsed.method === WORKER_PROTOCOL_METHODS[5];
|
||||
if (isLongSessionOperation) {
|
||||
if (sessionOperations.has(parsed.id)) {
|
||||
failFrame(1008, "invalid-frame");
|
||||
|
||||
@@ -31,6 +31,7 @@ const SESSION_TARGET_FIELDS_BY_METHOD = new Map<string, readonly SessionMutation
|
||||
["sessions.delete", ["key"]],
|
||||
["sessions.dispatch", ["key"]],
|
||||
["sessions.files.set", ["sessionKey"]],
|
||||
["sessions.github.publish", ["sessionKey"]],
|
||||
["sessions.fork", ["sessionKey"]],
|
||||
["sessions.patch", ["key"]],
|
||||
["sessions.pluginPatch", ["key"]],
|
||||
@@ -68,6 +69,7 @@ const REQUIRED_SESSION_TARGET_METHODS = new Set([
|
||||
"sessions.groups.delete",
|
||||
"sessions.groups.rename",
|
||||
"sessions.groups.update",
|
||||
"sessions.github.publish",
|
||||
"sessions.patch",
|
||||
"sessions.pluginPatch",
|
||||
"sessions.reclaim",
|
||||
|
||||
@@ -54,6 +54,8 @@ export type PlacementRecoveryDeps = {
|
||||
agentId: string;
|
||||
}) => Promise<WorkerWorkspaceResultConflict | undefined>;
|
||||
recoverPlacementMoves?: () => Promise<Set<string>>;
|
||||
prepareAcceptedWorkspacePublication?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
};
|
||||
|
||||
function pendingWorkerLossError(
|
||||
@@ -80,6 +82,15 @@ type WorkerOwnedPendingPlacement = Extract<
|
||||
{ state: "active" | "draining" }
|
||||
>;
|
||||
|
||||
async function prepareAcceptedPublication(
|
||||
deps: PlacementRecoveryDeps,
|
||||
claim: WorkerSessionTurnClaim,
|
||||
): Promise<void> {
|
||||
if (deps.prepareAcceptedWorkspacePublication) {
|
||||
await deps.prepareAcceptedWorkspacePublication(claim).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function completeRecoveredWorkspaceTeardown(params: {
|
||||
placements: WorkerDispatchPlacementStore;
|
||||
placement: WorkerOwnedPendingPlacement;
|
||||
@@ -244,6 +255,8 @@ export async function recoverPendingWorkspaceResults(
|
||||
) {
|
||||
await environments.destroy(active.environmentId);
|
||||
}
|
||||
await prepareAcceptedPublication(deps, turnClaim);
|
||||
await deps.publishAcceptedWorkspace?.(turnClaim);
|
||||
completeRecoveredWorkspaceTeardown({ placements, placement: active, turnClaim });
|
||||
await environments
|
||||
.stopTunnel(active.environmentId, active.activeOwnerEpoch)
|
||||
@@ -288,6 +301,7 @@ export async function recoverPendingWorkspaceResults(
|
||||
await reconciliation.verifyLocalStable();
|
||||
const conflictPaths = reconciliation.conflictPaths;
|
||||
if (pending.workspaceAcceptedAtMs === null) {
|
||||
await prepareAcceptedPublication(deps, turnClaim);
|
||||
placements.acceptWorkspaceResult(turnClaim);
|
||||
}
|
||||
if (conflictPaths.length > 0 && isWorkerWorkspaceResultCleanupRef(ownedStagedResultRef)) {
|
||||
@@ -313,6 +327,7 @@ export async function recoverPendingWorkspaceResults(
|
||||
...report,
|
||||
}),
|
||||
});
|
||||
await deps.publishAcceptedWorkspace?.(turnClaim);
|
||||
await settleStagedWorkspaceResult({
|
||||
placements,
|
||||
turnClaim,
|
||||
@@ -345,6 +360,8 @@ export async function recoverPendingWorkspaceResults(
|
||||
continue;
|
||||
}
|
||||
if (pending.workspaceAcceptedAtMs !== null && environment?.state === "destroyed") {
|
||||
await prepareAcceptedPublication(deps, turnClaim);
|
||||
await deps.publishAcceptedWorkspace?.(turnClaim);
|
||||
completeRecoveredWorkspaceTeardown({ placements, placement: active, turnClaim });
|
||||
continue;
|
||||
}
|
||||
@@ -395,6 +412,7 @@ export async function recoverPendingWorkspaceResults(
|
||||
},
|
||||
});
|
||||
const applied = await verifyReconciledWorkspaceFinal(reconciliation, quiescence);
|
||||
await prepareAcceptedPublication(deps, turnClaim);
|
||||
placements.acceptWorkspaceResult(turnClaim);
|
||||
const recordedStagedResultRef = placements
|
||||
.listPendingWorkspaceResults()
|
||||
@@ -423,6 +441,7 @@ export async function recoverPendingWorkspaceResults(
|
||||
...report,
|
||||
}),
|
||||
});
|
||||
await deps.publishAcceptedWorkspace?.(turnClaim);
|
||||
await settleStagedWorkspaceResult({
|
||||
placements,
|
||||
turnClaim,
|
||||
|
||||
@@ -97,9 +97,15 @@ describe("staged worker placement result recovery", () => {
|
||||
it("applies a staged pending result without a tunnel and reclaims the worker", async () => {
|
||||
const workspacePath = path.join(root, "same-worker-staged-result");
|
||||
const priorConflictRef = "refs/openclaw/worker-results/prior-conflict";
|
||||
const prepareAcceptedWorkspacePublication = vi.fn(async () => {
|
||||
throw new Error("publication snapshot rejected");
|
||||
});
|
||||
const publishAcceptedWorkspace = vi.fn(async () => undefined);
|
||||
const harness = createHarness(placementStore, {
|
||||
workspacePath,
|
||||
priorWorkspaceResultConflict: { paths: ["old.txt"], stagedResultRef: priorConflictRef },
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
});
|
||||
const active = harness.placements.seedActive(2);
|
||||
harness.markEnvironmentOwnerEpoch(2);
|
||||
@@ -146,6 +152,8 @@ describe("staged worker placement result recovery", () => {
|
||||
expect(placementStore.listPendingWorkspaceResults()).toEqual([]);
|
||||
expect(harness.environments.startTunnel).not.toHaveBeenCalled();
|
||||
expect(harness.environments.destroy).toHaveBeenCalledWith(active.environmentId);
|
||||
expect(prepareAcceptedWorkspacePublication).toHaveBeenCalledWith(claim);
|
||||
expect(publishAcceptedWorkspace).toHaveBeenCalledWith(claim);
|
||||
expect(
|
||||
(
|
||||
await runCommandWithTimeout(
|
||||
@@ -170,6 +178,58 @@ describe("staged worker placement result recovery", () => {
|
||||
).not.toBe(0);
|
||||
});
|
||||
|
||||
it("publishes an accepted result after cleanup removed its staged ref", async () => {
|
||||
const workspacePath = path.join(root, "accepted-result-missing-ref");
|
||||
const originalHarness = createHarness(placementStore, { workspacePath });
|
||||
const active = originalHarness.placements.seedActive(2);
|
||||
if (active.state !== "active") {
|
||||
throw new Error("active placement fixture was not active");
|
||||
}
|
||||
const claim = placementStore.claimTurn({
|
||||
...REQUEST,
|
||||
claimId: "accepted-result-missing-ref-claim",
|
||||
runId: "accepted-result-missing-ref-run",
|
||||
owner: {
|
||||
kind: "worker",
|
||||
environmentId: active.environmentId,
|
||||
ownerEpoch: active.activeOwnerEpoch,
|
||||
},
|
||||
});
|
||||
const staged = await stagePendingResult({
|
||||
store: placementStore,
|
||||
claim,
|
||||
workspacePath,
|
||||
base: "base\n",
|
||||
current: "worker\n",
|
||||
});
|
||||
placementStore.acceptWorkspaceResult(claim);
|
||||
placementStore.handoffWorkspaceResultRecovery(claim);
|
||||
expect(
|
||||
(
|
||||
await runCommandWithTimeout(
|
||||
["git", "-C", workspacePath, "update-ref", "-d", staged.stagedResultRef],
|
||||
{ timeoutMs: 10_000 },
|
||||
)
|
||||
).code,
|
||||
).toBe(0);
|
||||
const publishAcceptedWorkspace = vi.fn(async () => undefined);
|
||||
const restartedStore = createWorkerSessionPlacementStore({ database, now: () => 2_000 });
|
||||
const restartedHarness = createHarness(restartedStore, {
|
||||
workspacePath,
|
||||
publishAcceptedWorkspace,
|
||||
});
|
||||
restartedHarness.markEnvironmentDestroyed();
|
||||
|
||||
await restartedHarness.service.reconcile();
|
||||
|
||||
expect(publishAcceptedWorkspace).toHaveBeenCalledWith(claim);
|
||||
expect(restartedHarness.placements.current()).toMatchObject({
|
||||
state: "reclaimed",
|
||||
turnClaim: null,
|
||||
});
|
||||
expect(restartedStore.listPendingWorkspaceResults()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not destroy the worker while a nested session operation is running", async () => {
|
||||
const workspacePath = path.join(root, "running-session-operation");
|
||||
const harness = createHarness(placementStore, { workspacePath });
|
||||
|
||||
@@ -49,6 +49,12 @@ export function createHarness(
|
||||
terminalizedReclaimError?: Error;
|
||||
environmentGeneration?: number;
|
||||
failMoveAfterBegin?: boolean;
|
||||
prepareAcceptedWorkspacePublication?: Parameters<
|
||||
typeof createWorkerPlacementDispatchService
|
||||
>[0]["prepareAcceptedWorkspacePublication"];
|
||||
publishAcceptedWorkspace?: Parameters<
|
||||
typeof createWorkerPlacementDispatchService
|
||||
>[0]["publishAcceptedWorkspace"];
|
||||
} = {},
|
||||
) {
|
||||
const reconciledManifestRef = MANIFEST_REF.replaceAll("b", "c");
|
||||
@@ -390,6 +396,12 @@ export function createHarness(
|
||||
},
|
||||
reportWorkspaceResultConflict,
|
||||
resolveWorkspaceResultConflict: vi.fn(async () => options.priorWorkspaceResultConflict),
|
||||
...(options.prepareAcceptedWorkspacePublication
|
||||
? { prepareAcceptedWorkspacePublication: options.prepareAcceptedWorkspacePublication }
|
||||
: {}),
|
||||
...(options.publishAcceptedWorkspace
|
||||
? { publishAcceptedWorkspace: options.publishAcceptedWorkspace }
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
log,
|
||||
|
||||
@@ -86,7 +86,22 @@ describe("worker placement dispatch", () => {
|
||||
});
|
||||
|
||||
it("recovers a completed turn's durable pending workspace result before stale-claim teardown", async () => {
|
||||
const harness = createTestHarness();
|
||||
const publicationOrder: string[] = [];
|
||||
const prepareAcceptedWorkspacePublication = vi.fn(async (claim) => {
|
||||
expect(
|
||||
placementStore
|
||||
.listPendingWorkspaceResults()
|
||||
.find((pending) => pending.claimId === claim.claimId)?.workspaceAcceptedAtMs,
|
||||
).toBeNull();
|
||||
publicationOrder.push("prepare");
|
||||
});
|
||||
const publishAcceptedWorkspace = vi.fn(async () => {
|
||||
publicationOrder.push("publish");
|
||||
});
|
||||
const harness = createTestHarness({
|
||||
prepareAcceptedWorkspacePublication,
|
||||
publishAcceptedWorkspace,
|
||||
});
|
||||
const active = harness.placements.seedActive(2);
|
||||
harness.markEnvironmentOwnerEpoch(2);
|
||||
if (active.state !== "active") {
|
||||
@@ -163,6 +178,9 @@ describe("worker placement dispatch", () => {
|
||||
|
||||
await harness.service.reconcile();
|
||||
|
||||
expect(prepareAcceptedWorkspacePublication).toHaveBeenCalledWith(claim);
|
||||
expect(publishAcceptedWorkspace).toHaveBeenCalledWith(claim);
|
||||
expect(publicationOrder).toEqual(["prepare", "publish"]);
|
||||
expect(harness.placements.current()).toMatchObject({
|
||||
state: "active",
|
||||
turnClaim: null,
|
||||
@@ -341,7 +359,8 @@ describe("worker placement dispatch", () => {
|
||||
});
|
||||
|
||||
it("reclaims an accepted pending result after a post-destroy gateway restart", async () => {
|
||||
const harness = createTestHarness();
|
||||
const publishAcceptedWorkspace = vi.fn(async () => undefined);
|
||||
const harness = createTestHarness({ publishAcceptedWorkspace });
|
||||
const active = harness.placements.seedActive(2);
|
||||
if (active.state !== "active") {
|
||||
throw new Error("active placement fixture was not active");
|
||||
@@ -372,6 +391,7 @@ describe("worker placement dispatch", () => {
|
||||
turnClaim: null,
|
||||
workspaceBaseManifestRef: harness.reconciledManifestRef,
|
||||
});
|
||||
expect(publishAcceptedWorkspace).toHaveBeenCalledWith(claim);
|
||||
expect(placementStore.listPendingWorkspaceResults()).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -112,6 +112,13 @@ type WorkerPlacementDispatchOptions = WorkerPlacementReclaimBarriers & {
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
}) => Promise<WorkerWorkspaceResultConflict | undefined>;
|
||||
prepareAcceptedWorkspacePublication?: (
|
||||
claim: import("./placement-store.js").WorkerSessionTurnClaim,
|
||||
) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (
|
||||
claim: import("./placement-store.js").WorkerSessionTurnClaim,
|
||||
) => Promise<void>;
|
||||
resolveGitAuthor?: (agentId: string) => { name?: string; email?: string } | undefined;
|
||||
};
|
||||
|
||||
function requireProvisionedEnvironment(
|
||||
@@ -165,6 +172,12 @@ export function createWorkerPlacementDispatchService(options: WorkerPlacementDis
|
||||
resolveWorkspaceResultConflict: options.resolveWorkspaceResultConflict,
|
||||
recoverPlacementMoves: () => recoverPlacementMoves(),
|
||||
workspaceOperations: options.workspaceOperations,
|
||||
...(options.prepareAcceptedWorkspacePublication
|
||||
? { prepareAcceptedWorkspacePublication: options.prepareAcceptedWorkspacePublication }
|
||||
: {}),
|
||||
...(options.publishAcceptedWorkspace
|
||||
? { publishAcceptedWorkspace: options.publishAcceptedWorkspace }
|
||||
: {}),
|
||||
});
|
||||
|
||||
const reportTransition = (
|
||||
@@ -263,10 +276,12 @@ export function createWorkerPlacementDispatchService(options: WorkerPlacementDis
|
||||
});
|
||||
ownerEpoch = credential.ownerEpoch;
|
||||
const tunnel = await environments.startTunnel({ environmentId, ownerEpoch });
|
||||
const gitAuthor = options.resolveGitAuthor?.(request.agentId);
|
||||
const synced = await tunnel.syncWorkspace({
|
||||
localPath,
|
||||
sessionId: request.sessionId,
|
||||
generation: placement.generation,
|
||||
...(gitAuthor ? { gitAuthor } : {}),
|
||||
});
|
||||
placement = placements.transition({
|
||||
sessionId: request.sessionId,
|
||||
|
||||
@@ -265,6 +265,19 @@ export function createPlacementSessionToolOperationOps(runtime: PlacementStoreRu
|
||||
return hasToolAuthority(read(), claim, toolName);
|
||||
},
|
||||
|
||||
closeWorkerTurnToolAdmission(claim: WorkerSessionTurnClaim): void {
|
||||
if (claim.owner.kind !== "worker") {
|
||||
return;
|
||||
}
|
||||
write((db) => {
|
||||
exactWorkerClaim(db, claim);
|
||||
closeWorkerTurnToolAdmission(db, {
|
||||
sessionId: claim.sessionId,
|
||||
claimId: claim.claimId,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async closeWorkerTurnToolState(claim: WorkerSessionTurnClaim): Promise<void> {
|
||||
if (claim.owner.kind !== "worker") {
|
||||
write((db) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
WorkerGitHubPublishParams,
|
||||
WorkerSessionsSendParams,
|
||||
WorkerSessionsSpawnParams,
|
||||
WorkerSessionToolResult,
|
||||
@@ -98,6 +99,12 @@ type WorkerEnvironmentServiceOptions = WorkerProviderLifecycleInputOptions & {
|
||||
toolName: "sessions_send";
|
||||
request: WorkerSessionsSendParams;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
| {
|
||||
identity: WorkerConnectionIdentity;
|
||||
toolName: "github_publish";
|
||||
request: WorkerGitHubPublishParams;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => Promise<WorkerSessionToolResult>;
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ export type WorkerWorkspaceSyncRequest = {
|
||||
localPath: string;
|
||||
sessionId: string;
|
||||
generation: number;
|
||||
gitAuthor?: { name?: string; email?: string };
|
||||
};
|
||||
|
||||
export type WorkerWorkspaceSyncResult = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export function resolveWorkerBrowserLaunchPlan(params: {
|
||||
desktop: WorkerDesktopEndpoint | null;
|
||||
modelRef: { provider: string; model: string };
|
||||
turn: SessionPlacementTurnParams;
|
||||
githubPublicationAvailable?: boolean;
|
||||
}): {
|
||||
browser?: WorkerBrowserLaunchDescriptor;
|
||||
toolAuthority: WorkerToolAuthority;
|
||||
@@ -26,6 +27,7 @@ export function resolveWorkerBrowserLaunchPlan(params: {
|
||||
const toolAuthority = resolveWorkerToolAuthority({
|
||||
modelRef: params.modelRef,
|
||||
turn: params.turn,
|
||||
githubPublicationAvailable: params.githubPublicationAvailable,
|
||||
...(browserAvailable ? { availableOptionalToolNames: ["browser"] } : {}),
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -32,6 +32,7 @@ const gatewayRuntimeIdentity = vi.hoisted(() => vi.fn());
|
||||
const dispatchChild = vi.hoisted(() => vi.fn());
|
||||
const spawnCallerIdentity = vi.hoisted(() => vi.fn());
|
||||
const spawnArgs = vi.hoisted(() => vi.fn());
|
||||
const githubPublicationRequest = vi.hoisted(() => vi.fn());
|
||||
const scopedSessionAccess = vi.hoisted(() =>
|
||||
vi.fn(async (params: { run: () => Promise<unknown> }) => await params.run()),
|
||||
);
|
||||
@@ -167,7 +168,11 @@ describe("worker session tool topology", () => {
|
||||
ownerEpoch: SOURCE.ownerEpoch,
|
||||
},
|
||||
});
|
||||
placements.authorizeWorkerTurnTools(sourceClaim, ["sessions_send", "sessions_spawn"]);
|
||||
placements.authorizeWorkerTurnTools(sourceClaim, [
|
||||
"sessions_send",
|
||||
"sessions_spawn",
|
||||
"github_publish",
|
||||
]);
|
||||
delegatedAuthorities = [];
|
||||
const sourceOperationalRun = createOperationalRunInstanceRef(sourceClaim.runId);
|
||||
delegatedAuthorities.push(claimAgentRunDelegatedAuthority(sourceOperationalRun));
|
||||
@@ -198,6 +203,12 @@ describe("worker session tool topology", () => {
|
||||
dispatchChild.mockReset();
|
||||
spawnCallerIdentity.mockReset();
|
||||
spawnArgs.mockReset();
|
||||
githubPublicationRequest.mockReset();
|
||||
githubPublicationRequest.mockResolvedValue({
|
||||
requestId: "publication-1",
|
||||
status: "requested",
|
||||
message: "Publication was accepted.",
|
||||
});
|
||||
scopedSessionAccess.mockClear();
|
||||
childSessionKey = undefined;
|
||||
spawnOrder = [];
|
||||
@@ -234,6 +245,7 @@ describe("worker session tool topology", () => {
|
||||
execute = createWorkerSessionToolExecutor({
|
||||
placements,
|
||||
dispatchChild,
|
||||
githubPublication: { requestForClaim: githubPublicationRequest },
|
||||
environments: {
|
||||
get: (environmentId: string) => {
|
||||
if (environmentId === SOURCE.environmentId) {
|
||||
@@ -272,6 +284,67 @@ describe("worker session tool topology", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records publication intent with the exact claim and no credential fields", async () => {
|
||||
setEntry(SOURCE.sessionKey, SOURCE.sessionId);
|
||||
|
||||
const result = await execute({
|
||||
identity,
|
||||
toolName: "github_publish",
|
||||
request: {
|
||||
toolCallId: "publish-cloud-work",
|
||||
title: "Publish the cloud fix",
|
||||
},
|
||||
});
|
||||
|
||||
expect(JSON.parse(result.resultJson)).toMatchObject({
|
||||
details: { requestId: "publication-1", status: "requested" },
|
||||
});
|
||||
expect(githubPublicationRequest).toHaveBeenCalledWith({
|
||||
claim: sourceClaim,
|
||||
sessionKey: SOURCE.sessionKey,
|
||||
agentId: SOURCE.agentId,
|
||||
idempotencyKey: "publish-cloud-work",
|
||||
title: "Publish the cloud fix",
|
||||
assertCurrent: expect.any(Function),
|
||||
});
|
||||
expect(JSON.stringify(githubPublicationRequest.mock.calls)).not.toContain("token");
|
||||
});
|
||||
|
||||
it("revalidates publication authority after awaited Gateway work", async () => {
|
||||
setEntry(SOURCE.sessionKey, SOURCE.sessionId);
|
||||
githubPublicationRequest.mockImplementationOnce(async (request) => {
|
||||
placements.closeWorkerTurnToolAdmission(sourceClaim);
|
||||
request.assertCurrent?.();
|
||||
return {
|
||||
requestId: "unreachable",
|
||||
status: "requested",
|
||||
message: "unreachable",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
execute({
|
||||
identity,
|
||||
toolName: "github_publish",
|
||||
request: { toolCallId: "publish-lost-authority" },
|
||||
}),
|
||||
).rejects.toThrow("Worker session tool authority changed");
|
||||
});
|
||||
|
||||
it("rejects publication when the exact turn was not granted the tool", async () => {
|
||||
setEntry(SOURCE.sessionKey, SOURCE.sessionId);
|
||||
placements.authorizeWorkerTurnTools(sourceClaim, ["sessions_send"]);
|
||||
|
||||
await expect(
|
||||
execute({
|
||||
identity,
|
||||
toolName: "github_publish",
|
||||
request: { toolCallId: "publish-without-authority" },
|
||||
}),
|
||||
).rejects.toThrow("Worker session tool authority changed");
|
||||
expect(githubPublicationRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const authority of delegatedAuthorities) {
|
||||
releaseAgentRunDelegatedAuthority(authority);
|
||||
@@ -437,7 +510,7 @@ describe("worker session tool topology", () => {
|
||||
sessionSpawnContext: {
|
||||
inheritedToolPolicy: {
|
||||
version: 1,
|
||||
allow: ["sessions_spawn", "sessions_send"],
|
||||
allow: ["sessions_spawn", "sessions_send", "github_publish"],
|
||||
deny: [],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import type {
|
||||
WorkerGitHubPublishParams,
|
||||
WorkerSessionsSendParams,
|
||||
WorkerSessionsSpawnParams,
|
||||
WorkerSessionToolResult,
|
||||
} from "../../../packages/gateway-protocol/src/schema/worker-admission.js";
|
||||
import {
|
||||
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
|
||||
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
|
||||
} from "../../../packages/gateway-protocol/src/schema/worker-protocol-primitives.js";
|
||||
import { buildSubagentExecutionSessionSpawnContext } from "../../agents/subagents/spawn/subagent-spawn-execution-identity.js";
|
||||
import {
|
||||
getGatewayToolCallerIdentity,
|
||||
@@ -27,9 +23,9 @@ import { jsonResult } from "../../agents/tools/tool-results.js";
|
||||
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../../config/agent-limits.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { sha256Base64Url, sha256HexPrefixCore } from "../../infra/crypto-digest.js";
|
||||
import { redactSensitiveText } from "../../logging/redact.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { WORKER_TOOL_NAMES } from "../../worker/tool-authority.js";
|
||||
import type { GitHubPublicationCoordinator } from "../github-publication.js";
|
||||
import { loadGatewaySessionEntryReadOnly } from "../session-utils.js";
|
||||
import type { WorkerConnectionIdentity } from "./connection-identity.js";
|
||||
import type { WorkerSessionPlacementStore } from "./placement-store.js";
|
||||
@@ -39,6 +35,10 @@ import {
|
||||
} from "./placement-turn-claim-events.js";
|
||||
import type { WorkerPlacementDispatchContract } from "./service-contract.js";
|
||||
import type { WorkerEnvironmentService } from "./service.js";
|
||||
import {
|
||||
serializeWorkerSessionToolResult as serializeResult,
|
||||
workerSessionToolErrorResult as errorResult,
|
||||
} from "./worker-session-tool-result.js";
|
||||
import {
|
||||
assertWorkerSessionToolChild as assertExactChild,
|
||||
resolveWorkerSessionToolSource as exactSource,
|
||||
@@ -60,6 +60,12 @@ type WorkerSessionToolRequest =
|
||||
toolName: "sessions_send";
|
||||
request: WorkerSessionsSendParams;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
| {
|
||||
identity: WorkerConnectionIdentity;
|
||||
toolName: "github_publish";
|
||||
request: WorkerGitHubPublishParams;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
class WorkerSessionToolOutcomeUnknownError extends Error {
|
||||
@@ -77,37 +83,6 @@ function operationKey(operationSeed: string, purpose: string): string {
|
||||
return sha256Base64Url(`openclaw.worker-session-tool-operation.v1\0${operationSeed}\0${purpose}`);
|
||||
}
|
||||
|
||||
function errorResult(error: unknown) {
|
||||
const message = redactSensitiveText(
|
||||
error instanceof Error ? error.message : "Worker session operation failed",
|
||||
{ mode: "tools" },
|
||||
);
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error: truncateUtf16Safe(message, 1_024),
|
||||
});
|
||||
}
|
||||
|
||||
function responseFrameBytes(resultJson: string): number {
|
||||
return Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: "x".repeat(WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH),
|
||||
ok: true,
|
||||
payload: { resultJson },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function serializeResult(result: unknown): string {
|
||||
const resultJson = JSON.stringify(result);
|
||||
if (responseFrameBytes(resultJson) > WORKER_PROTOCOL_MAX_PAYLOAD_BYTES) {
|
||||
return JSON.stringify(errorResult(new Error("Worker session tool result exceeded the limit")));
|
||||
}
|
||||
return resultJson;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
@@ -124,6 +99,7 @@ export function createWorkerSessionToolExecutor(params: {
|
||||
placements: WorkerSessionPlacementStore;
|
||||
environments: Pick<WorkerEnvironmentService, "get">;
|
||||
dispatchChild: WorkerPlacementDispatchContract["dispatch"];
|
||||
githubPublication: Pick<GitHubPublicationCoordinator, "requestForClaim">;
|
||||
}) {
|
||||
const inFlight = new Map<string, Promise<string>>();
|
||||
|
||||
@@ -544,6 +520,27 @@ export function createWorkerSessionToolExecutor(params: {
|
||||
|
||||
return async (request: WorkerSessionToolRequest): Promise<WorkerSessionToolResult> => {
|
||||
const source = exactSource({ identity: request.identity, placements: params.placements });
|
||||
if (request.toolName === "github_publish") {
|
||||
const assertPublicationAuthority = () => {
|
||||
const current = exactSource({ identity: request.identity, placements: params.placements });
|
||||
if (!params.placements.isWorkerTurnToolAuthorized(current.turnClaim, request.toolName)) {
|
||||
throw new Error("Worker session tool authority changed");
|
||||
}
|
||||
};
|
||||
assertPublicationAuthority();
|
||||
throwIfAborted(request.signal);
|
||||
const publication = await params.githubPublication.requestForClaim({
|
||||
claim: source.turnClaim,
|
||||
sessionKey: source.sessionKey,
|
||||
agentId: source.agentId,
|
||||
idempotencyKey: request.request.toolCallId,
|
||||
...(request.request.title ? { title: request.request.title } : {}),
|
||||
...(request.request.body ? { body: request.request.body } : {}),
|
||||
assertCurrent: assertPublicationAuthority,
|
||||
});
|
||||
assertPublicationAuthority();
|
||||
return { resultJson: serializeResult(jsonResult(publication)) };
|
||||
}
|
||||
const requestDigest = computeRequestDigest(
|
||||
request.toolName === "sessions_spawn"
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import {
|
||||
WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH,
|
||||
WORKER_PROTOCOL_MAX_PAYLOAD_BYTES,
|
||||
} from "../../../packages/gateway-protocol/src/schema/worker-protocol-primitives.js";
|
||||
import { jsonResult } from "../../agents/tools/tool-results.js";
|
||||
import { redactSensitiveText } from "../../logging/redact.js";
|
||||
|
||||
export function workerSessionToolErrorResult(error: unknown) {
|
||||
const message = redactSensitiveText(
|
||||
error instanceof Error ? error.message : "Worker session operation failed",
|
||||
{ mode: "tools" },
|
||||
);
|
||||
return jsonResult({
|
||||
status: "error",
|
||||
error: truncateUtf16Safe(message, 1_024),
|
||||
});
|
||||
}
|
||||
|
||||
function responseFrameBytes(resultJson: string): number {
|
||||
return Buffer.byteLength(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: "x".repeat(WORKER_PROTOCOL_MAX_FRAME_ID_LENGTH),
|
||||
ok: true,
|
||||
payload: { resultJson },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeWorkerSessionToolResult(result: unknown): string {
|
||||
const resultJson = JSON.stringify(result);
|
||||
if (responseFrameBytes(resultJson) > WORKER_PROTOCOL_MAX_PAYLOAD_BYTES) {
|
||||
return JSON.stringify(
|
||||
workerSessionToolErrorResult(new Error("Worker session tool result exceeded the limit")),
|
||||
);
|
||||
}
|
||||
return resultJson;
|
||||
}
|
||||
@@ -18,10 +18,14 @@ function turn(overrides: Partial<SessionPlacementTurnParams> = {}): SessionPlace
|
||||
} as SessionPlacementTurnParams;
|
||||
}
|
||||
|
||||
function authority(overrides: Partial<SessionPlacementTurnParams> = {}) {
|
||||
function authority(
|
||||
overrides: Partial<SessionPlacementTurnParams> = {},
|
||||
githubPublicationAvailable = false,
|
||||
) {
|
||||
return resolveWorkerToolAuthority({
|
||||
modelRef: { provider: "openai", model: "gpt-test" },
|
||||
turn: turn(overrides),
|
||||
githubPublicationAvailable,
|
||||
}).allowedToolNames;
|
||||
}
|
||||
|
||||
@@ -72,6 +76,13 @@ describe("resolveWorkerToolAuthority", () => {
|
||||
expect(authority({ toolsAllow: [] })).toEqual([]);
|
||||
expect(authority({ toolsAllow: ["web_search"] })).toEqual([]);
|
||||
expect(authority({ toolsAllow: ["sessions_send"] })).toEqual(["sessions_send"]);
|
||||
expect(authority({ toolsAllow: ["github_publish"] })).toEqual([]);
|
||||
expect(authority({ toolsAllow: ["github_publish"] }, true)).toEqual(["github_publish"]);
|
||||
});
|
||||
|
||||
it("adds publication only when the Gateway prepared its capability", () => {
|
||||
expect(authority()).not.toContain("github_publish");
|
||||
expect(authority({}, true)).toContain("github_publish");
|
||||
});
|
||||
|
||||
it("uses scheduled owner group policy without reapplying fresh sender overlays", () => {
|
||||
|
||||
@@ -75,6 +75,7 @@ export function resolveWorkerToolAuthority(params: {
|
||||
modelRef: { provider: string; model: string };
|
||||
turn: SessionPlacementTurnParams;
|
||||
availableOptionalToolNames?: readonly WorkerOptionalLocalToolName[];
|
||||
githubPublicationAvailable?: boolean;
|
||||
}): WorkerToolAuthority {
|
||||
const turn = params.turn;
|
||||
if (turn.disableTools === true || turn.modelRun === true || turn.promptMode === "none") {
|
||||
@@ -84,7 +85,9 @@ export function resolveWorkerToolAuthority(params: {
|
||||
[
|
||||
...WORKER_REQUIRED_LOCAL_TOOL_NAMES,
|
||||
...(params.availableOptionalToolNames ?? []),
|
||||
...WORKER_SESSION_TOOL_NAMES,
|
||||
...WORKER_SESSION_TOOL_NAMES.filter(
|
||||
(name) => name !== "github_publish" || params.githubPublicationAvailable === true,
|
||||
),
|
||||
].map((name) => ({ name })),
|
||||
turn.toolsAllow,
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import { emitAgentRunStatusEvent } from "../../infra/agent-run-status-events.js"
|
||||
import { redactSensitiveText } from "../../logging/redact.js";
|
||||
import { parseWorkerLaunchPlan } from "../../worker/launch-descriptor.js";
|
||||
import { WORKER_PROVIDER_REPLAY_LOCAL_RETRY_MESSAGE } from "../../worker/transcript-message.js";
|
||||
import { prepareGitHubPublicationAvailability } from "../github-publication-availability.js";
|
||||
import {
|
||||
STALE_WORKER_BUILD_REASON,
|
||||
StaleWorkerBuildError,
|
||||
@@ -71,6 +72,8 @@ type WorkerTurnLauncherOptions = {
|
||||
reconcileActivePlacement: (environmentId: string) => Promise<void>;
|
||||
workspaceOperations: WorkerWorkspaceOperationCoordinator;
|
||||
redispatchReclaimed: (placement: ReclaimedWorkerPlacement) => Promise<ActiveWorkerPlacement>;
|
||||
prepareAcceptedWorkspacePublication?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
};
|
||||
|
||||
async function executeLocalTurn<T>(params: {
|
||||
@@ -105,6 +108,8 @@ async function executeWorkerTurn(params: {
|
||||
turn: SessionPlacementTurnParams;
|
||||
turnClaim: WorkerSessionTurnClaim;
|
||||
localWorkspaceDir: string;
|
||||
prepareAcceptedWorkspacePublication?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
}) {
|
||||
const { placement, turn } = params;
|
||||
const modelRef = assertSupportedTurn(turn);
|
||||
@@ -132,6 +137,12 @@ async function executeWorkerTurn(params: {
|
||||
);
|
||||
}
|
||||
await recoverWorkspaceBeforeTurn(params);
|
||||
const githubPublicationAvailable = await prepareGitHubPublicationAvailability({
|
||||
sessionId: placement.sessionId,
|
||||
sessionKey: placement.sessionKey,
|
||||
agentId: placement.agentId,
|
||||
assertCurrent: () => params.placements.validateTurnClaim(params.turnClaim),
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
turn.onExecutionStarted?.({ lifecycleGeneration: turn.lifecycleGeneration });
|
||||
@@ -201,6 +212,7 @@ async function executeWorkerTurn(params: {
|
||||
desktop: environment.desktop,
|
||||
modelRef,
|
||||
turn,
|
||||
githubPublicationAvailable,
|
||||
});
|
||||
params.placements.authorizeWorkerTurnTools(params.turnClaim, toolAuthority.allowedToolNames);
|
||||
const { operationalRunInstance, runtimeIdentity } = await prepareWorkerAgentRuntimeIdentity({
|
||||
@@ -360,6 +372,12 @@ async function executeWorkerTurn(params: {
|
||||
localWorkspaceDir: params.localWorkspaceDir,
|
||||
transcriptTarget,
|
||||
tunnel,
|
||||
...(params.prepareAcceptedWorkspacePublication
|
||||
? { prepareAcceptedWorkspacePublication: params.prepareAcceptedWorkspacePublication }
|
||||
: {}),
|
||||
...(params.publishAcceptedWorkspace
|
||||
? { publishAcceptedWorkspace: params.publishAcceptedWorkspace }
|
||||
: {}),
|
||||
});
|
||||
if (workspaceConflict) {
|
||||
const reportedWorkspaceConflict = workspaceConflict;
|
||||
@@ -531,6 +549,12 @@ export function createWorkerSessionTurnPlacementProvider(options: WorkerTurnLaun
|
||||
placements: options.placements,
|
||||
reconcileActivePlacement: options.reconcileActivePlacement,
|
||||
localWorkspaceDir,
|
||||
...(options.prepareAcceptedWorkspacePublication
|
||||
? { prepareAcceptedWorkspacePublication: options.prepareAcceptedWorkspacePublication }
|
||||
: {}),
|
||||
...(options.publishAcceptedWorkspace
|
||||
? { publishAcceptedWorkspace: options.publishAcceptedWorkspace }
|
||||
: {}),
|
||||
workspaceOperations: options.workspaceOperations,
|
||||
turn,
|
||||
turnClaim,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
WorkerGitHubPublishParams,
|
||||
WorkerConnectParams,
|
||||
WorkerLiveEventParams,
|
||||
WorkerProtocolCloseReason,
|
||||
@@ -108,6 +109,12 @@ type WorkerTurnRpcOptions = {
|
||||
toolName: "sessions_send";
|
||||
request: WorkerSessionsSendParams;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
| {
|
||||
identity: WorkerConnectionIdentity;
|
||||
toolName: "github_publish";
|
||||
request: WorkerGitHubPublishParams;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => Promise<WorkerSessionToolResult>;
|
||||
inference: ReturnType<typeof createWorkerInferenceManager>;
|
||||
@@ -319,7 +326,7 @@ export function createWorkerTurnRpc(options: WorkerTurnRpcOptions) {
|
||||
const executeSessionTool = async (
|
||||
identity: WorkerConnectionIdentity,
|
||||
toolName: WorkerSessionToolName,
|
||||
request: WorkerSessionsSpawnParams | WorkerSessionsSendParams,
|
||||
request: WorkerSessionsSpawnParams | WorkerSessionsSendParams | WorkerGitHubPublishParams,
|
||||
signal?: AbortSignal,
|
||||
): Promise<WorkerSessionToolServiceResult> => {
|
||||
const validate = () => {
|
||||
@@ -354,12 +361,20 @@ export function createWorkerTurnRpc(options: WorkerTurnRpcOptions) {
|
||||
request: request as WorkerSessionsSpawnParams,
|
||||
...(signal ? { signal } : {}),
|
||||
}
|
||||
: {
|
||||
identity,
|
||||
toolName,
|
||||
request: request as WorkerSessionsSendParams,
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
: toolName === "sessions_send"
|
||||
? {
|
||||
identity,
|
||||
toolName,
|
||||
request: request as WorkerSessionsSendParams,
|
||||
...(signal ? { signal } : {}),
|
||||
}
|
||||
: {
|
||||
identity,
|
||||
toolName,
|
||||
// SAFETY: worker-connection validates params with the method-specific schema.
|
||||
request: request as WorkerGitHubPublishParams,
|
||||
...(signal ? { signal } : {}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return { ok: false, reason: "gateway-unavailable" };
|
||||
|
||||
@@ -115,6 +115,8 @@ export async function reconcileWorkspaceAfterTurn(params: {
|
||||
localWorkspaceDir: string;
|
||||
transcriptTarget: Parameters<typeof SessionManager.open>[0];
|
||||
tunnel: WorkerTunnelHandle;
|
||||
prepareAcceptedWorkspacePublication?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
}): Promise<WorkspaceConflictReport | undefined> {
|
||||
const currentPlacement = params.placements.get(params.placement.sessionId);
|
||||
const generationMatches =
|
||||
@@ -175,6 +177,9 @@ export async function reconcileWorkspaceAfterTurn(params: {
|
||||
if (!journal.wasAccepted()) {
|
||||
throw new Error("Cloud worker workspace reconciliation was not durably accepted");
|
||||
}
|
||||
if (params.prepareAcceptedWorkspacePublication) {
|
||||
await params.prepareAcceptedWorkspacePublication(params.turnClaim).catch(() => undefined);
|
||||
}
|
||||
params.placements.acceptWorkspaceResult(params.turnClaim);
|
||||
const recordedStagedResultRef = params.placements
|
||||
.listPendingWorkspaceResults()
|
||||
@@ -223,6 +228,7 @@ export async function reconcileWorkspaceAfterTurn(params: {
|
||||
);
|
||||
},
|
||||
});
|
||||
await params.publishAcceptedWorkspace?.(params.turnClaim);
|
||||
await settleStagedWorkspaceResult({
|
||||
placements: params.placements,
|
||||
turnClaim: params.turnClaim,
|
||||
@@ -279,6 +285,8 @@ export async function executeRemoteExecTurn(params: {
|
||||
turnClaim: WorkerSessionTurnClaim;
|
||||
localWorkspaceDir: string;
|
||||
runLocal: () => Promise<EmbeddedAgentRunResult>;
|
||||
prepareAcceptedWorkspacePublication?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
publishAcceptedWorkspace?: (claim: WorkerSessionTurnClaim) => Promise<void>;
|
||||
}): Promise<EmbeddedAgentRunResult> {
|
||||
const environment = params.environments.get(params.placement.environmentId);
|
||||
if (
|
||||
@@ -318,6 +326,12 @@ export async function executeRemoteExecTurn(params: {
|
||||
localWorkspaceDir: params.localWorkspaceDir,
|
||||
transcriptTarget,
|
||||
tunnel,
|
||||
...(params.prepareAcceptedWorkspacePublication
|
||||
? { prepareAcceptedWorkspacePublication: params.prepareAcceptedWorkspacePublication }
|
||||
: {}),
|
||||
...(params.publishAcceptedWorkspace
|
||||
? { publishAcceptedWorkspace: params.publishAcceptedWorkspace }
|
||||
: {}),
|
||||
});
|
||||
if (executionError) {
|
||||
throw executionError instanceof Error
|
||||
|
||||
@@ -337,6 +337,14 @@ export function validateWorkspaceSyncRequest(request: WorkerWorkspaceSyncRequest
|
||||
if (!Number.isSafeInteger(request.generation) || request.generation < 0) {
|
||||
throw new Error("Worker workspace generation must be a non-negative safe integer");
|
||||
}
|
||||
for (const value of [request.gitAuthor?.name, request.gitAuthor?.email]) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
(!value.trim() || value.length > 256 || value.includes("\u0000") || /[\r\n]/u.test(value))
|
||||
) {
|
||||
throw new Error("Worker workspace Git author metadata is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRemoteWorkspaceSetup(
|
||||
|
||||
@@ -74,6 +74,10 @@ describe("worker tunnel manager", () => {
|
||||
localPath,
|
||||
sessionId: "session:one",
|
||||
generation: 7,
|
||||
gitAuthor: {
|
||||
name: "roboclaw-bot",
|
||||
email: "42+roboclaw-bot@users.noreply.github.com",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ mode: "git", remoteWorkspaceDir, manifestRef });
|
||||
|
||||
@@ -96,6 +100,12 @@ describe("worker tunnel manager", () => {
|
||||
entry.argv.at(-1)?.includes("worker workspace symlink escapes"),
|
||||
);
|
||||
expect(manifest?.argv.at(-1)).toContain(commit);
|
||||
const gitSetup = fake.runs.find(
|
||||
(entry) =>
|
||||
entry.argv.join("\0").includes(remoteWorkspaceDir) &&
|
||||
entry.argv.join("\0").includes("42+roboclaw-bot@users.noreply.github.com"),
|
||||
);
|
||||
expect(gitSetup?.argv.join("\0")).toContain("roboclaw-bot");
|
||||
} finally {
|
||||
await handle.stop();
|
||||
await fs.rm(localPath, { recursive: true });
|
||||
|
||||
@@ -298,7 +298,7 @@ export function createWorkerWorkspaceActions(
|
||||
if (!success(packTransfer)) {
|
||||
throw workspaceSyncError(packTransfer);
|
||||
}
|
||||
const [authorName, authorEmail] = await Promise.all(
|
||||
const [detectedAuthorName, detectedAuthorEmail] = await Promise.all(
|
||||
["user.name", "user.email"].map(async (key) => {
|
||||
const result = await runTask(
|
||||
["git", "-C", gitRoot, "config", "--get", key],
|
||||
@@ -310,6 +310,8 @@ export function createWorkerWorkspaceActions(
|
||||
return success(result) ? result.stdout.trim() : "";
|
||||
}),
|
||||
);
|
||||
const authorName = request.gitAuthor?.name ?? detectedAuthorName;
|
||||
const authorEmail = request.gitAuthor?.email ?? detectedAuthorEmail;
|
||||
const seeded = await runWorkspaceCommand({
|
||||
transportRetry: "never",
|
||||
argv: [
|
||||
|
||||
@@ -42,6 +42,7 @@ export const LAZY_ADDITIVE_STATE_TABLES = [
|
||||
"user_preferences",
|
||||
"device_pair_setup_completions",
|
||||
"gateway_origin_device_tokens",
|
||||
"github_publication_requests",
|
||||
"device_pairing_join_codes",
|
||||
"sidebar_sections",
|
||||
"skill_workshop_proposal_events",
|
||||
@@ -56,6 +57,7 @@ export const LAZY_ADDITIVE_STATE_INDEXES = [
|
||||
...FIRST_USE_STATE_INDEXES,
|
||||
"idx_cron_run_receipts_active_job",
|
||||
"idx_cron_run_receipts_job_history",
|
||||
"idx_github_publication_requests_pending",
|
||||
"idx_skill_workshop_collection_reviews_workspace_time",
|
||||
"secret_store_entries_live_idx",
|
||||
] as const;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user