Harden sentry-triage repo/URL metadata validation and split issue/PR repos (#2815)

* Harden sentry-triage repo/URL metadata validation and split issue/PR repos

The canvas relays model-reported GitHub artifact URLs and numbers (from a
turn that also ingests untrusted Sentry text) into tracking badges and the
"Fix with Copilot" flow. Validate that data against trusted, outside-the-model
anchors so injected text can't mint misleading badges or steer a write to the
wrong repo, and support issue/PR repos that legitimately differ.

- Add path-type-aware URL validation (repoRefNumber/urlInRepo): a URL must be
  http(s), on the trusted host, in the expected owner/repo, and match the exact
  artifact kind (/issues/<n> vs /pull/<n>) with a path-segment boundary after
  the id. The id must be a positive safe integer. This rejects look-alikes like
  /pull/123evil, cross-kind spoofs (an issue URL minting a "PR #N" badge), and
  overflow/zero ids.
- Split the issue anchor from the PR anchor (deriveRepoAnchors): the tracking
  issue and the fix PR can live in different repos. Cloud mode anchors the PR on
  the issue/cloud repo; an explicitly selected local project anchors it on that
  project's config-time repo (frozen at save time); "Current project" anchors it
  on the trusted current-project repo from the git remote — never the issue repo.
  A missing/malformed PR repo fails closed with a clear, actionable error.
- Derive tracking issue/PR numbers from the validated URLs (not the model), and
  strip every pr* field when no concrete /pull/<n> in the PR repo is present, so
  a badge can't point at a different artifact than the one verified.
- Freeze the selected project's repo into PR-target config at save time
  (page.mjs/state.mjs/server.mjs) and include mode + local.repo in the
  repo-change identity so switching projects re-derives annotations.
- Roll the optimistic rescan overlay back on a failed /api/refresh POST instead
  of leaving a blocking overlay up until the fallback timer expires.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Derive displayed numbers from validated URLs; isolate project enumeration

Address Copilot review on #2815.

Comment #2: every displayed issue/PR number is now re-extracted from the
same URL that was validated against the expected repo, instead of trusting
the model-reported *Number field. Fixes the skipped/dedup, done, and
submit_work_pr paths so a reply pairing /pull/1 with prNumber 999 can no
longer render "PR #999" linking to PR 1. Numbers with no concrete anchor
(placeholder repo / non-GitHub tracker) keep the model value.

Comment #1: the projectId->repo mapping is a security anchor (it decides
which repo fix-session PRs are validated against), so it must not be
produced in a turn that also ingests untrusted Sentry titles. Enumeration
now runs in its own Sentry-free turn (loadProjectOptions), and the save
path re-binds the repo/name server-side from the selected projectId against
trusted project state; the browser no longer supplies the auth repo.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Demote sentry-triage project map from an authorization anchor

The projectId->repo map returned by the model's list_projects tool was
being used as the trust anchor for fix-session PR validation, and was
isolated into a separate "project enumeration" turn to keep untrusted
Sentry titles out of the prompt that produced it. But that turn joins the
same foreground conversation, so it is not a real trust boundary.

The SDK exposes no isolated model context and no trusted project-
enumeration API, so instead stop treating the model-relayed repo as an
authorization anchor at all. An explicitly selected project is authorized
by its host-resolved project_id (a trusted handle at create_session time),
so deriveRepoAnchors now returns prExpectedRepo='' + prProjectSelected for
that case and the preflight/URL gates skip the unbackable repo check. The
map is now display/dedup-search only, so enumeration is folded back into
the triage round-trip and the separate turn is removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix fallout from demoting the sentry-triage project map

Follow-ups from review after the project-map demotion:

- Reject stale/forged local project IDs in set-pr-config. An unknown
  non-empty projectId left boundProject null but was still stored, and
  deriveRepoAnchors treats any non-empty id as an explicit selection —
  which skips the missing-repo/PR-URL preflight. Store the id only when it
  resolves to a known project, so an unresolvable id fails closed to ''
  (Current-project mode, which requires a trusted git remote).

- Restore tracked-PR badges for split-repo selected projects. The tracked
  badge validated the model-reported PR URL against the now-empty trusted
  PR anchor, so repoRefNumber always returned null and every discovered PR
  was stripped. The badge is read-only display, not a write gate, so anchor
  its search + URL/number validation on the model-declared project repo
  when there is no trusted anchor (still pins number->URL and host).

- Update set-pr-config and page.mjs comments: the bound repo/name are
  display + dedup-search metadata, not PR authorization, and drop the stale
  loadProjectOptions / "Sentry-free turn" references.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fail closed on an unconfirmable dedup skip for selected projects

After demoting the project map, prExpectedRepo is '' for an explicitly
selected project, so the dedup "already being worked on" skip accepted a
bare model-reported PR number as authoritative. Because the dedup result
and the project list come from the same Sentry-bearing model turn, injected
text could forge a skip to both suppress the trusted host-resolved
create_session flow and surface an arbitrary PR link.

An authoritative skip suppresses the trusted flow and surfaces a PR link,
so it now requires a trusted PR-repo anchor: a URL-bearing PR validated
against a concrete prExpectedRepo. A selected project has no trusted anchor,
so its skip is unconfirmable and fails closed to a retryable error instead
of being honored. The downstream dedup validation is now only reached with a
concrete PR anchor, so its dead no-anchor branches are removed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Anchor Current-project PR repo to the checkout cwd, not GITHUB_REPOSITORY

runtimeDefaults.repo prefers GITHUB_REPOSITORY, which is the configured ISSUE
target and may point at a separate cloud repo; seedDefaultsFromSession() does not
overwrite an explicit GITHUB_REPOSITORY. All three deriveRepoAnchors calls passed
that value as currentProjectRepo, so when the issue repo differed from the driving
checkout, Current-project mode gated PR validation and dedup on the issue repo and
rejected legitimate PRs from the current project.

Derive the Current-project anchor unconditionally from the resolved session cwd via
currentCheckoutRepo(defaults) = repoFromPath(localPath), which co-derives from the
same git remote as the trusted host, and use it at all three anchor derivations.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Restrict local Copilot handoff to the current project

Remove the model-relayed "selected project" local write-handoff and its
whole plumbing (the Settings project dropdown, submit_projects/list_projects
enumeration, projectOptions state, and the server-side projectId re-bind).

The dropdown was the only thing feeding a model-supplied project_id into
create_session, and the only local mode whose PR repo came solely from the
untrusted Sentry triage turn. That made it both the write-steering vector the
reviewer flagged (server.mjs set-pr-config / deriveRepoAnchors) and the one
local mode where a dedup "skip" could never be verified against a trusted repo.

Local "Work on selected" now always runs the fix session in the CURRENT
project (the canvas's own checkout, host-trusted from its git remote), so the
PR repo is always a trusted anchor and dedup is always verifiable. Cross-repo
work goes through Cloud mode, whose repo the user types directly in Settings.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Liz Tom
2026-08-27 11:35:35 +10:00
committed by GitHub
co-authored by Copilot App
parent 6859615e53
commit c72fb21484
5 changed files with 326 additions and 306 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ issue** or **Fix with Copilot**.
## Agent tools ## Agent tools
The canvas exposes structured hand-off tools the agent calls instead of printing The canvas exposes structured hand-off tools the agent calls instead of printing
JSON into the timeline: `submit_issue_summaries`, `submit_projects`, JSON into the timeline: `submit_issue_summaries`,
`submit_tracking`, `submit_related`, and `submit_work_pr`. `submit_tracking`, `submit_related`, and `submit_work_pr`.
## License ## License
+16 -69
View File
@@ -48,7 +48,6 @@ export function Page({
prTargets, prTargets,
prSettingsOpen, prSettingsOpen,
plainEnglishView = false, plainEnglishView = false,
projectOptions = [],
projects = [], projects = [],
availableModels = [], availableModels = [],
issueTrackers, issueTrackers,
@@ -162,17 +161,6 @@ export function Page({
`</select></label>` `</select></label>`
: '' : ''
// Registered app projects for the Local hand-off target dropdown. Loaded
// eagerly on open via the agent, so this may be empty on the very first render.
const projectList = Array.isArray(projectOptions) ? projectOptions : []
const localProjectId = prTargets && prTargets.local ? (prTargets.local.projectId || '') : ''
const projectOptionsHtml = projectList
.map((p) => {
const label = p.repo ? `${p.name} (${p.repo})` : p.name
return `<option value="${escapeHtml(p.id)}"${p.id === localProjectId ? ' selected' : ''}>${escapeHtml(label)}</option>`
})
.join('')
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
@@ -292,13 +280,7 @@ export function Page({
<p id="mode-hint" class="settings-hint"></p> <p id="mode-hint" class="settings-hint"></p>
<div id="local-group" class="settings-subgroup${prTargets && prTargets.mode === 'cloud' ? ' dimmed' : ''}"> <div id="local-group" class="settings-subgroup${prTargets && prTargets.mode === 'cloud' ? ' dimmed' : ''}">
<span class="settings-group-title">Local target</span> <span class="settings-group-title">Local target</span>
<label class="settings-label"> <p class="settings-hint">Runs in the <strong>current project</strong> (this checkout). For a different repo, use Cloud mode.</p>
Project
<select id="local-project" class="settings-input">
<option value="">Current project (default)</option>
${projectOptionsHtml}
</select>
</label>
<div class="settings-row"> <div class="settings-row">
<label class="settings-label"> <label class="settings-label">
Local path Local path
@@ -619,7 +601,6 @@ export function Page({
let currentProject = ${jsonForScript(project)}; let currentProject = ${jsonForScript(project)};
let currentPeriod = ${jsonForScript(period)}; let currentPeriod = ${jsonForScript(period)};
let currentPeriods = ${jsonForScript(periodList)}; let currentPeriods = ${jsonForScript(periodList)};
let currentProjectOptions = ${jsonForScript(projectList)};
// Sentry project slugs for the current org (SDK-discovered). Drives the // Sentry project slugs for the current org (SDK-discovered). Drives the
// project autocomplete; empty = fall back to a typed slug box. // project autocomplete; empty = fall back to a typed slug box.
let currentSentryProjects = ${jsonForScript(projectSlugs)}; let currentSentryProjects = ${jsonForScript(projectSlugs)};
@@ -858,14 +839,8 @@ export function Page({
if (draftMode === "cloud") { if (draftMode === "cloud") {
draftTarget = (currentPrTargets?.cloud?.repo || "current repo") + " @ " + (currentPrTargets?.cloud?.baseBranch || "default"); draftTarget = (currentPrTargets?.cloud?.repo || "current repo") + " @ " + (currentPrTargets?.cloud?.baseBranch || "default");
} else { } else {
const projName = currentPrTargets?.local?.projectName || "";
const projId = currentPrTargets?.local?.projectId || "";
const localBase = currentPrTargets?.local?.baseBranch || "default"; const localBase = currentPrTargets?.local?.baseBranch || "default";
if (projId) { draftTarget = (currentPrTargets?.local?.path || "current project") + " @ " + localBase;
draftTarget = (projName || projId) + " @ " + localBase;
} else {
draftTarget = (currentPrTargets?.local?.path || "current project") + " @ " + localBase;
}
} }
const modelId = currentPrTargets?.model || ""; const modelId = currentPrTargets?.model || "";
const models = Array.isArray(currentAvailableModels) ? currentAvailableModels : []; const models = Array.isArray(currentAvailableModels) ? currentAvailableModels : [];
@@ -940,25 +915,6 @@ export function Page({
syncCardModels(); syncCardModels();
syncModelControls(); syncModelControls();
const localProject = document.getElementById("local-project");
if (localProject) {
const selectedId = currentPrTargets?.local?.projectId || "";
const options = Array.isArray(currentProjectOptions) ? currentProjectOptions : [];
localProject.innerHTML = "";
const base = document.createElement("option");
base.value = "";
base.textContent = "Current project (default)";
localProject.appendChild(base);
options.forEach((p) => {
const option = document.createElement("option");
option.value = p.id;
option.textContent = p.repo ? (p.name + " (" + p.repo + ")") : p.name;
option.selected = p.id === selectedId;
localProject.appendChild(option);
});
localProject.value = selectedId;
}
const localPath = document.getElementById("local-path"); const localPath = document.getElementById("local-path");
if (localPath) localPath.value = currentPrTargets?.local?.path || ""; if (localPath) localPath.value = currentPrTargets?.local?.path || "";
const localBranch = document.getElementById("local-branch"); const localBranch = document.getElementById("local-branch");
@@ -1023,7 +979,6 @@ export function Page({
if (psel && psel.value !== msg.period) psel.value = msg.period; if (psel && psel.value !== msg.period) psel.value = msg.period;
} }
if (Array.isArray(msg.periods)) currentPeriods = msg.periods; if (Array.isArray(msg.periods)) currentPeriods = msg.periods;
if (Array.isArray(msg.projectOptions)) currentProjectOptions = msg.projectOptions;
if (Array.isArray(msg.projects)) { if (Array.isArray(msg.projects)) {
const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : ""; const forOrg = typeof msg.projectsOrg === "string" ? msg.projectsOrg.trim().toLowerCase() : "";
// Always cache under the org this list belongs to so re-selecting it is // Always cache under the org this list belongs to so re-selecting it is
@@ -1655,10 +1610,23 @@ export function Page({
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const btn = e.target.closest("#refresh, #rescan"); const btn = e.target.closest("#refresh, #rescan");
if (!btn) return; if (!btn) return;
fetch("/api/refresh", { method: "POST" });
const subtitle = document.querySelector(".page-subtitle"); const subtitle = document.querySelector(".page-subtitle");
const prevSubtitle = subtitle ? subtitle.textContent : "";
if (subtitle) subtitle.textContent = "Scanning Sentry..."; if (subtitle) subtitle.textContent = "Scanning Sentry...";
showScanOverlay(currentProject ? "Scanning " + currentProject + "…" : "Scanning all projects…"); showScanOverlay(currentProject ? "Scanning " + currentProject + "…" : "Scanning all projects…");
// Like the org/period rescans, verify the POST was accepted and roll the
// optimistic overlay back on failure. Fire-and-forget would otherwise leave
// the blocking overlay up (no SSE update ever arrives to clear it) until the
// long fallback timer expires when the loopback server is unreachable.
fetch("/api/refresh", { method: "POST" })
.then((res) => {
if (!res.ok) throw new Error("refresh " + res.status);
})
.catch(() => {
hideScanOverlay();
if (subtitle) subtitle.textContent = prevSubtitle;
window.alert("Couldn't start a rescan — the triage server may have stopped responding. Please try again.");
});
}); });
// Both "Create issue" and "Fix with Copilot" optimistically paint their // Both "Create issue" and "Fix with Copilot" optimistically paint their
@@ -1735,37 +1703,16 @@ export function Page({
document.addEventListener("change", (e) => { document.addEventListener("change", (e) => {
if (e.target && e.target.id === "pr-mode") updateModeHint(); if (e.target && e.target.id === "pr-mode") updateModeHint();
if (e.target && e.target.id === "local-project") {
const id = e.target.value || "";
const match = (Array.isArray(currentProjectOptions) ? currentProjectOptions : []).find((p) => p.id === id);
const localPath = document.getElementById("local-path");
if (localPath) localPath.value = match && match.path ? match.path : "";
// Keep the base branch in step with the selected project. Leaving the
// previous project's branch here would pair the new projectId with a
// stale (possibly nonexistent) base on save; clearing it falls back to
// this project's own default branch.
const localBranch = document.getElementById("local-branch");
if (localBranch) localBranch.value = match && match.defaultBranch ? match.defaultBranch : "";
}
}); });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const btn = e.target.closest("#save-pr-config"); const btn = e.target.closest("#save-pr-config");
if (!btn) return; if (!btn) return;
const localProjectEl = document.getElementById("local-project");
const localProjectId = localProjectEl?.value || "";
let localProjectName = "";
if (localProjectId) {
const match = (Array.isArray(currentProjectOptions) ? currentProjectOptions : []).find((p) => p.id === localProjectId);
localProjectName = match ? match.name : "";
}
const payload = { const payload = {
mode: document.getElementById("pr-mode")?.value || "local", mode: document.getElementById("pr-mode")?.value || "local",
model: document.getElementById("toolbar-model")?.value || "", model: document.getElementById("toolbar-model")?.value || "",
localPath: document.getElementById("local-path")?.value || "", localPath: document.getElementById("local-path")?.value || "",
localBranch: document.getElementById("local-branch")?.value || "", localBranch: document.getElementById("local-branch")?.value || "",
localProjectId: localProjectId,
localProjectName: localProjectName,
cloudRepo: document.getElementById("cloud-repo")?.value || "", cloudRepo: document.getElementById("cloud-repo")?.value || "",
cloudBranch: document.getElementById("cloud-branch")?.value || "", cloudBranch: document.getElementById("cloud-branch")?.value || "",
}; };
+300 -203
View File
@@ -22,29 +22,107 @@ function safeSentryUrl(value) {
return '' return ''
} }
// True iff `value` is an http(s) URL whose HOST is exactly `allowedHost` AND // Path shapes for the GitHub artifact kinds we validate. Anchoring on the exact
// whose path is a GitHub issue/PR in exactly `expectedRepo` (owner/repo), i.e. // segment matters for correctness AND safety: a PR URL must never pass as an
// https://<allowedHost>/<owner>/<repo>/(issues|pull)/<n>. Validating the host as // issue, and — critically — an issue URL must never be trusted as a PR and mint a
// well as owner/repo is essential: a path-only check would accept a look-alike // bogus "PR #N" badge. The `(?:\/|$)` boundary after the id is required so a
// like https://attacker.example/<owner>/<repo>/pull/1 and let a model-reported // look-alike like `/pull/123evil` or `/issues/7anything` can't be truncated to a
// link masquerade as living in the authorized repository. `expectedRepo` and // valid id — only the exact artifact path or one of its subpaths validates. `any`
// `allowedHost` come from trusted config (Settings / local git / env), never from // is only for soft "related" hints that may be either kind.
// the model or Sentry. Any parse failure, host mismatch, or shape mismatch → false const REPO_REF_PATTERNS = {
// (fail closed). issue: /^\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\/|$)/,
function urlInRepo(value, expectedRepo, allowedHost) { pull: /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/,
if (!expectedRepo || !allowedHost) return false any: /^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)(?:\/|$)/,
}
// Parse a model-reported URL and return its numeric id ONLY when it is an http(s)
// URL whose HOST is exactly `allowedHost` and whose path is a GitHub artifact of
// the requested `kind` in exactly `expectedRepo` (owner/repo), i.e.
// https://<allowedHost>/<owner>/<repo>/(issues|pull)/<n>. Returns null on any
// parse failure, host mismatch, repo mismatch, or shape mismatch (fail closed).
// Validating the host as well as owner/repo is essential: a path-only check would
// accept a look-alike like https://attacker.example/<owner>/<repo>/pull/1 and let
// a model-reported link masquerade as living in the authorized repository.
// `expectedRepo` and `allowedHost` come from trusted config (Settings / local git
// / env), never from the model or Sentry. The id must be a positive safe integer:
// an unbounded digit run can parse to Infinity or a precision-losing value, and
// any non-null result here is treated as verified, so reject those too.
function repoRefNumber(value, expectedRepo, allowedHost, kind = 'any') {
if (!expectedRepo || !allowedHost) return null
const href = safeSentryUrl(value) const href = safeSentryUrl(value)
if (!href) return false if (!href) return null
try { try {
const url = new URL(href) const url = new URL(href)
if (url.hostname.toLowerCase() !== String(allowedHost).toLowerCase()) return false if (url.hostname.toLowerCase() !== String(allowedHost).toLowerCase()) return null
const m = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(?:issues|pull)\/\d+/) const m = url.pathname.match(REPO_REF_PATTERNS[kind] || REPO_REF_PATTERNS.any)
return !!m && `${m[1]}/${m[2]}`.toLowerCase() === String(expectedRepo).toLowerCase() if (!m || `${m[1]}/${m[2]}`.toLowerCase() !== String(expectedRepo).toLowerCase()) return null
const n = Number(m[3])
if (!Number.isSafeInteger(n) || n <= 0) return null
return n
} catch { } catch {
return false return null
} }
} }
// Boolean form: does `value` point at a GitHub artifact of `kind` in the expected
// repo on the trusted host? Fails closed (false) on any mismatch.
function urlInRepo(value, expectedRepo, allowedHost, kind = 'any') {
return repoRefNumber(value, expectedRepo, allowedHost, kind) !== null
}
// Validate a candidate GitHub "owner/repo" and return it lowercased, or '' when
// blank/malformed (fails closed). Restrict each component to the characters GitHub
// actually allows so a masquerading slug — `owner/repo?tab=x`, `owner/#frag`,
// `owner/..`, or one carrying a path/query/fragment — can't slip through a lax
// "one slash" check and authorize a write. Owner: alphanumerics and hyphens, no
// leading/trailing hyphen, <=39 chars. Repo: alphanumerics plus `.`, `-`, `_`,
// <=100 chars, but never the reserved `.` or `..` segments.
function normalizeRepo(value) {
const raw = String(value || '').trim()
const m = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/.exec(raw)
if (!m) return ''
const [, owner, repo] = m
if (owner.startsWith('-') || owner.endsWith('-')) return ''
if (repo === '.' || repo === '..') return ''
return raw.toLowerCase()
}
// Derive the two repo anchors the split-repo model enforces:
// - expectedRepo: where the tracking ISSUE is filed (the issue/cloud repo).
// - prExpectedRepo: the TRUSTED repo a fix-session PR is gated against, or ''
// when no trusted anchor exists (in which case no URL gate is applied).
// Only anchors from TRUSTED sources are used — never the model or injected Sentry
// text:
// - Cloud mode: the fix session runs on the cloud repo (user-typed in Settings),
// where the issue is also filed, so the PR anchor is that issue repo.
// - Local mode: the fix session ALWAYS runs in the canvas's OWN checkout (the
// "Current project"), so the PR lands in the trusted current-project repo
// (`currentProjectRepo`, seeded from the session cwd's git remote) — NOT the
// issue repo, which Settings may point at a separate cloud repo. There is no
// model-relayed "selected project" handoff: routing a fix session by a
// model-supplied project_id would authorize the write with a repo we could only
// learn from the same untrusted Sentry turn, so that path was removed. Cross-repo
// work goes through Cloud mode (repo typed in Settings = trusted).
// Both repo values are '' unless a concrete "owner/repo" resolves.
function deriveRepoAnchors(prTargets, issueRepo, currentProjectRepo) {
const expectedRepo = normalizeRepo(issueRepo)
const isCloudMode = prTargets?.mode === 'cloud'
if (isCloudMode) return { expectedRepo, prExpectedRepo: expectedRepo }
return { expectedRepo, prExpectedRepo: normalizeRepo(currentProjectRepo) }
}
// The TRUSTED current-checkout repo for the "Current project" PR anchor. Always
// derived from the resolved session cwd (localPath), NEVER from runtimeDefaults.repo:
// that value prefers GITHUB_REPOSITORY, which is the configured ISSUE target and may
// point at a separate (cloud) repo. seedDefaultsFromSession() deliberately does not
// overwrite an explicit GITHUB_REPOSITORY, so feeding runtimeDefaults.repo in as the
// current-project anchor would gate PR validation/dedup on the issue repo and reject
// legitimate PRs opened from the driving checkout. localPath is the resolved cwd, so
// its git remote is the checkout actually being driven — the right, host-trusted anchor.
function currentCheckoutRepo(defaults) {
return repoFromPath(defaults?.localPath || '')
}
// Every model turn (scan enrichment and each work item) runs on the ONE shared // Every model turn (scan enrichment and each work item) runs on the ONE shared
// `session`. The SDK drives a single conversation, so two overlapping // `session`. The SDK drives a single conversation, so two overlapping
// `sendAndWait` calls would interleave prompts and replies on the same thread — // `sendAndWait` calls would interleave prompts and replies on the same thread —
@@ -463,6 +541,12 @@ function buildWorkPrompt({ key, issue, org, prTargets, model: modelOverride, ass
const prMode = prTargets?.mode === 'cloud' ? 'cloud' : 'local' const prMode = prTargets?.mode === 'cloud' ? 'cloud' : 'local'
const model = modelOverride || prTargets?.model || '' const model = modelOverride || prTargets?.model || ''
const targetRepo = issueRepo || cloudRepo || defaults.repo || '(current repository)' const targetRepo = issueRepo || cloudRepo || defaults.repo || '(current repository)'
// The draft PR is opened where the fix session runs. Use a best-effort PR-repo
// hint for the Step-0 open-PR dedup SEARCH only (never an authorization anchor):
// the trusted PR anchor when we have one, else the issue repo. Issue
// lookup/creation stays on the issue repo (targetRepo).
const { prExpectedRepo } = deriveRepoAnchors(prTargets, issueRepo, currentCheckoutRepo(defaults))
const prSearchRepo = prExpectedRepo || targetRepo
const plain = sanitizeForPrompt(issue.plainEnglish || issue.summary || 'User-visible failure in production', 200) const plain = sanitizeForPrompt(issue.plainEnglish || issue.summary || 'User-visible failure in production', 200)
const issueTitle = `[sentry-triage][${key}] ${plain}`.slice(0, 120) const issueTitle = `[sentry-triage][${key}] ${plain}`.slice(0, 120)
const markerLabel = 'sentry-triage' const markerLabel = 'sentry-triage'
@@ -562,24 +646,16 @@ Return JSON only (no markdown), using one of these shapes:
} }
const sessionLocation = prMode === 'cloud' ? 'cloud' : 'local' const sessionLocation = prMode === 'cloud' ? 'cloud' : 'local'
const localProjectId = prTargets?.local?.projectId || ''
const localProjectName = prTargets?.local?.projectName || ''
// Local mode can target a specific registered project (chosen in Settings). When
// one is picked we tell the agent to create the session in THAT project by id;
// otherwise it falls back to the current project the canvas is driving.
const createSessionTarget = sessionLocation === 'local' && localProjectId
? `- Use the create_session tool targeting project_id "${localProjectId}"${localProjectName ? ` (project "${localProjectName}")` : ''}.`
: '- Use the create_session tool in the CURRENT project.'
const prFlow = [ const prFlow = [
'Step 2 (Draft PR): Spin up a DEDICATED, separate session for THIS bug only — do NOT draft the PR inline in the current session.', 'Step 2 (Draft PR): Spin up a DEDICATED, separate session for THIS bug only — do NOT draft the PR inline in the current session.',
createSessionTarget, '- Use the create_session tool in the CURRENT project.',
model model
? `- Run that session under model "${model}": pass model: "${model}" to the create_session tool.` ? `- Run that session under model "${model}": pass model: "${model}" to the create_session tool.`
: '- Let that session use its default model (do not set the model parameter).', : '- Let that session use its default model (do not set the model parameter).',
`- execution_location: "${sessionLocation}".`, `- execution_location: "${sessionLocation}".`,
sessionLocation === 'cloud' sessionLocation === 'cloud'
? `- Cloud target repo: ${cloudRepo || '(current repository)'}; base ref: ${cloudBase || 'repository default'}.` ? `- Cloud target repo: ${cloudRepo || '(current repository)'}; base ref: ${cloudBase || 'repository default'}.`
: `- Local checkout: ${localProjectId ? `project "${localProjectName || localProjectId}" (${localPath})` : localPath}; base branch: ${localBase || 'repository default'}.`, : `- Local checkout: ${localPath}; base branch: ${localBase || 'repository default'}.`,
`- Name the session after the bug (e.g. "Fix ${key}").`, `- Name the session after the bug (e.g. "Fix ${key}").`,
'- Keep coordinate_with_creator on so the spawned session reports its PR back.', '- Keep coordinate_with_creator on so the spawned session reports its PR back.',
'- Provide a kickoff prompt (autopilot mode) instructing that session to:', '- Provide a kickoff prompt (autopilot mode) instructing that session to:',
@@ -592,13 +668,13 @@ Return JSON only (no markdown), using one of these shapes:
// Step 0 dedup is tracker-aware: the ISSUE may live in Linear/Jira, but the // Step 0 dedup is tracker-aware: the ISSUE may live in Linear/Jira, but the
// draft PR is always a GitHub PR, so we always also check GitHub for an open PR. // draft PR is always a GitHub PR, so we always also check GitHub for an open PR.
const dedupBlock = selectedTracker === 'github' const dedupBlock = selectedTracker === 'github'
? `0. BEFORE creating anything, check whether this Sentry issue is ALREADY BEING WORKED ON in repo ${targetRepo}. "Being worked on" means an OPEN pull request exists for it — nothing else counts: ? `0. BEFORE creating anything, check whether this Sentry issue is ALREADY BEING WORKED ON. "Being worked on" means an OPEN pull request exists for it — nothing else counts:
- Search OPEN PRs for "${key}" and "${urlSafe}", preferring the marker label "${markerLabel}" and/or title/body references. - Search OPEN PRs in the PR repo ${prSearchRepo} for "${key}" and "${urlSafe}", preferring the marker label "${markerLabel}" and/or title/body references.
- A CLOSED or MERGED PR does NOT count as active work. If the only PR you find is closed/merged, this is NOT a duplicate — proceed to Steps 1-2 to open fresh work. - A CLOSED or MERGED PR does NOT count as active work. If the only PR you find is closed/merged, this is NOT a duplicate — proceed to Steps 1-2 to open fresh work.
- Also look for an existing OPEN tracking issue (marker label "${markerLabel}", or title/body references "${key}"). An open issue on its own, with NO open PR, does NOT count as "being worked on" — but remember it so Step 1 can REUSE it instead of filing a duplicate. - Also look in the issue repo ${targetRepo} for an existing OPEN tracking issue (marker label "${markerLabel}", or title/body references "${key}"). An open issue on its own, with NO open PR, does NOT count as "being worked on" — but remember it so Step 1 can REUSE it instead of filing a duplicate.
- ONLY if an OPEN PR exists: STOP. Do not create anything. Return JSON with status "skipped" and the existing open PR's number, url, and state "open" (or "draft" for a draft PR) (and its issue if any).` - ONLY if an OPEN PR exists: STOP. Do not create anything. Return JSON with status "skipped" and the existing open PR's number, url, and state "open" (or "draft" for a draft PR) (and its issue if any).`
: `0. BEFORE creating anything, check whether this Sentry issue is ALREADY BEING WORKED ON. "Being worked on" means an OPEN GitHub pull request exists for it — nothing else counts: : `0. BEFORE creating anything, check whether this Sentry issue is ALREADY BEING WORKED ON. "Being worked on" means an OPEN GitHub pull request exists for it — nothing else counts:
- In GitHub repo ${targetRepo}: search OPEN PRs for "${key}" and "${urlSafe}". - In GitHub PR repo ${prSearchRepo}: search OPEN PRs for "${key}" and "${urlSafe}".
- A CLOSED or MERGED PR does NOT count as active work. If the only PR you find is closed/merged, this is NOT a duplicate — proceed to Steps 1-2. - A CLOSED or MERGED PR does NOT count as active work. If the only PR you find is closed/merged, this is NOT a duplicate — proceed to Steps 1-2.
- In tracker "${selectedTrackerLabel}" (id: ${selectedTracker}): look for an existing OPEN issue referencing "${key}". An open issue on its own, with NO open PR, does NOT count as "being worked on" — but remember it so Step 1 can REUSE it instead of filing a duplicate. - In tracker "${selectedTrackerLabel}" (id: ${selectedTracker}): look for an existing OPEN issue referencing "${key}". An open issue on its own, with NO open PR, does NOT count as "being worked on" — but remember it so Step 1 can REUSE it instead of filing a duplicate.
- ONLY if an OPEN PR exists: STOP. Do not create anything. Return JSON with status "skipped" and the existing open PR's number, url, and state "open" (or "draft" for a draft PR) (and its issue if any).` - ONLY if an OPEN PR exists: STOP. Do not create anything. Return JSON with status "skipped" and the existing open PR's number, url, and state "open" (or "draft" for a draft PR) (and its issue if any).`
@@ -787,21 +863,6 @@ async function enrichPlainEnglish(entry, categories, isCurrent) {
const total = all.length const total = all.length
const noun = total === 1 ? 'issue' : 'issues' const noun = total === 1 ? 'issue' : 'issues'
// Fold the one-time project enumeration into THIS triage round-trip instead of
// firing a separate "Loading your projects…" turn — projects persist in state,
// so we only tack the ask on when we don't have them yet. The extra tool calls
// are silent; the user still just sees "Triaging Sentry issues…".
const loadedProjects = entry.state.getProjectOptions()
const needProjects = !Array.isArray(loadedProjects) || loadedProjects.length === 0
const projectsToken = needProjects ? makeProjectsToken() : ''
const projectsBlock = needProjects
? `
Also, in the SAME turn, silently load the user's registered app projects for the canvas Settings dropdown (do not mention this in your reply):
- Call the tool "list_projects".
- Then call the tool "submit_projects" exactly once with token "${projectsToken}" (unchanged) and projects: an array of { id, name, repo, defaultBranch, path } using each project's id, name, github repo, default branch, and local main_repo_path (empty string if a field is missing).`
: ''
// Fold tracking detection into the same round-trip: the canvas files tracking // Fold tracking detection into the same round-trip: the canvas files tracking
// issues with the "sentry-triage" label and the Sentry key in the title, so a // issues with the "sentry-triage" label and the Sentry key in the title, so a
// single label search of the target repo (matched by key) tells us which board // single label search of the target repo (matched by key) tells us which board
@@ -811,6 +872,13 @@ Also, in the SAME turn, silently load the user's registered app projects for the
const trackRepo = selectedTracker === 'github' const trackRepo = selectedTracker === 'github'
? (trackPrTargets?.cloud?.repo || runtimeDefaults.repo || '') ? (trackPrTargets?.cloud?.repo || runtimeDefaults.repo || '')
: '' : ''
// Issue vs PR repo split for enrichment: the tracking ISSUE lives in trackRepo, but
// its linked PR can live in a DIFFERENT repo in local mode (the fix session runs in
// the current checkout, whose repo may differ from the issue repo). deriveRepoAnchors
// yields the trusted PR anchor for the tracked-PR BADGE — Cloud mode: the configured
// repo; Local mode: the current checkout's repo. Falls back to the issue repo last.
const { prExpectedRepo: trackPrExpectedRepo } = deriveRepoAnchors(trackPrTargets, trackRepo, currentCheckoutRepo(runtimeDefaults))
const trackPrRepo = trackPrExpectedRepo || normalizeRepo(trackRepo)
const needTracking = !!trackRepo const needTracking = !!trackRepo
const trackingToken = needTracking ? makeTrackingToken() : '' const trackingToken = needTracking ? makeTrackingToken() : ''
const trackingBlock = needTracking const trackingBlock = needTracking
@@ -818,7 +886,7 @@ Also, in the SAME turn, silently load the user's registered app projects for the
Also, in the SAME turn, silently detect which of these Sentry issues already have a tracking GitHub issue/PR (best-effort; do not mention this in your reply, and if the searches fail or find nothing just call the tool with an empty object): Also, in the SAME turn, silently detect which of these Sentry issues already have a tracking GitHub issue/PR (best-effort; do not mention this in your reply, and if the searches fail or find nothing just call the tool with an empty object):
- Tracking issues were filed with the label "sentry-triage" and contain the Sentry key in their title. Search the repo ${trackRepo} for them, e.g. run: gh search issues --repo ${trackRepo} --label sentry-triage --limit 100 --json number,title,url,state - Tracking issues were filed with the label "sentry-triage" and contain the Sentry key in their title. Search the repo ${trackRepo} for them, e.g. run: gh search issues --repo ${trackRepo} --label sentry-triage --limit 100 --json number,title,url,state
- For each Sentry key listed below, if a tracking issue's title contains that EXACT key, record it. Then find that issue's linked/closing pull request, e.g. gh issue view <number> --repo ${trackRepo} --json number,url,state,closedByPullRequestsReferences (or: gh pr list --repo ${trackRepo} --state all --search "<key>" --json number,url,state,isDraft). - For each Sentry key listed below, if a tracking issue's title contains that EXACT key, record it. Then find that issue's linked/closing pull request (the PR may live in a DIFFERENT repo, ${trackPrRepo}), e.g. gh issue view <number> --repo ${trackRepo} --json number,url,state,closedByPullRequestsReferences (or: gh pr list --repo ${trackPrRepo} --state all --search "<key>" --json number,url,state,isDraft).
- Call the tool "submit_tracking" exactly once with token "${trackingToken}" (unchanged) and tracking: an object mapping each matched Sentry key to { issueNumber, issueUrl, issueState, prNumber, prUrl, prState }. Include ONLY keys that have a tracking issue; omit the pr* fields when there is no linked PR. Use lowercase state strings: "open", "closed", "merged", or "draft" (use "draft" for an open draft PR).` - Call the tool "submit_tracking" exactly once with token "${trackingToken}" (unchanged) and tracking: an object mapping each matched Sentry key to { issueNumber, issueUrl, issueState, prNumber, prUrl, prState }. Include ONLY keys that have a tracking issue; omit the pr* fields when there is no linked PR. Use lowercase state strings: "open", "closed", "merged", or "draft" (use "draft" for an open draft PR).`
: '' : ''
@@ -841,7 +909,7 @@ Do NOT use class names, exception type names, stack-trace terms, file paths, or
Do NOT print the summaries or any JSON in your reply. Instead call the tool "submit_issue_summaries" exactly once with: Do NOT print the summaries or any JSON in your reply. Instead call the tool "submit_issue_summaries" exactly once with:
- token: "${token}" (pass it back unchanged) - token: "${token}" (pass it back unchanged)
- summaries: an object mapping each issue key to its sentence, e.g. {"PROJ-123":"Shoppers cannot complete checkout after clicking pay."}${projectsBlock}${trackingBlock}${relatedBlock} - summaries: an object mapping each issue key to its sentence, e.g. {"PROJ-123":"Shoppers cannot complete checkout after clicking pay."}${trackingBlock}${relatedBlock}
After the tool call(s), reply to the user with a confirmation sentence, then a blank line, then a clearly-labeled next-step note (no summaries, no JSON). Format it exactly like this (keep the blank line and the bold heading): After the tool call(s), reply to the user with a confirmation sentence, then a blank line, then a clearly-labeled next-step note (no summaries, no JSON). Format it exactly like this (keep the blank line and the bold heading):
@@ -883,16 +951,13 @@ ${items.map((i) => `${i.key}: ${i.title}`).join('\n')}`
try { try {
if (summariesInbox.has(token)) map = summariesInbox.get(token) if (summariesInbox.has(token)) map = summariesInbox.get(token)
// A newer scan may have started while this enrichment turn was in flight (up // A newer scan may have started while this enrichment turn was in flight (up
// to 240s). Applying THIS turn's project/tracking data to entry.state now // to 240s). Applying THIS turn's tracking/related data to entry.state now
// would clobber the newer scan's already-published state, so bail out of all // would clobber the newer scan's already-published state, so bail out of all
// shared-state writes when we're stale. We still fall through to the finally // shared-state writes when we're stale. We still fall through to the finally
// block (drop the inbox tokens) and the local plainEnglish loop below only // block (drop the inbox tokens) and the local plainEnglish loop below only
// mutates this scan's own — now detached — category objects, which the caller // mutates this scan's own — now detached — category objects, which the caller
// won't publish once isCurrent() is false. // won't publish once isCurrent() is false.
const current = typeof isCurrent !== 'function' || isCurrent() const current = typeof isCurrent !== 'function' || isCurrent()
if (current && projectsToken && projectsInbox.has(projectsToken)) {
entry.state.setProjectOptions(projectsInbox.get(projectsToken))
}
// Only rewrite tracked badges when the model actually submitted tracking // Only rewrite tracked badges when the model actually submitted tracking
// (an empty {} still counts). If the turn died before submit_tracking ran, // (an empty {} still counts). If the turn died before submit_tracking ran,
// leave any prior badges in place rather than wiping them. // leave any prior badges in place rather than wiping them.
@@ -905,22 +970,41 @@ ${items.map((i) => `${i.key}: ${i.title}`).join('\n')}`
// badge we verify (1) the key is a real board issue for THIS scope and // badge we verify (1) the key is a real board issue for THIS scope and
// (2) every link it carries lives on the trusted host + configured repo. // (2) every link it carries lives on the trusted host + configured repo.
// A record whose issueUrl can't be confirmed (missing, wrong host, wrong // A record whose issueUrl can't be confirmed (missing, wrong host, wrong
// repo, or bad shape), or whose prUrl is present but doesn't match, is // repo, or bad shape) is discarded entirely. The PR is a separate, weaker
// discarded — a look-alike link must never mint a Tracked badge. // signal: its pr* fields are kept ONLY when a concrete prUrl validates
// against the PR repo — otherwise they are stripped while the authorized
// issue is retained. A look-alike (or URL-less) link must never mint a badge.
const validKeys = new Set() const validKeys = new Set()
for (const category of categories) { for (const category of categories) {
for (const issue of category.issues) validKeys.add(issue.key) for (const issue of category.issues) validKeys.add(issue.key)
} }
const trackExpectedRepo = /^[^/\s]+\/[^/\s]+$/.test(String(trackRepo || '')) const trackExpectedRepo = normalizeRepo(trackRepo)
? String(trackRepo).toLowerCase()
: ''
const allowedHost = runtimeDefaults.host || 'github.com' const allowedHost = runtimeDefaults.host || 'github.com'
for (const [issueKey, info] of Object.entries(tracking)) { for (const [issueKey, info] of Object.entries(tracking)) {
if (!validKeys.has(issueKey)) continue if (!validKeys.has(issueKey)) continue
if (!info || typeof info !== 'object') continue if (!info || typeof info !== 'object') continue
if (!urlInRepo(info.issueUrl, trackExpectedRepo, allowedHost)) continue // The tracking ISSUE is the source of truth: its URL must be an issue in
if (info.prUrl && !urlInRepo(info.prUrl, trackExpectedRepo, allowedHost)) continue // the issue repo or the whole record is worthless — drop it. Require the
entry.state.setTrackedWorkStatus(issueKey, info) // `/issues/<n>` shape so a PR URL can't masquerade as the issue, and derive
// issueNumber from that validated URL so a mismatched model-reported number
// can't render "issue #999" while linking to a different /issues/<n>.
const verifiedIssueNumber = repoRefNumber(info.issueUrl, trackExpectedRepo, allowedHost, 'issue')
if (verifiedIssueNumber === null) continue
// Trust the PR's pr* fields ONLY when a concrete prUrl is a `/pull/<n>` in
// the PR anchor (which can differ from the issue repo in split-repo local
// mode — see trackPrRepo). A model-reported prNumber/prState
// with NO url, an issue URL, or a url in the wrong repo is unverifiable:
// strip EVERY pr* field (the card renders a badge from prNumber alone, and
// prState alone can keep a closed issue "tracked") but KEEP the authorized
// issue. Derive prNumber from the validated URL itself so a mismatched
// model-reported number can never render a badge pointing at a different PR.
const verifiedPrNumber = info.prUrl
? repoRefNumber(info.prUrl, trackPrRepo, allowedHost, 'pull')
: null
const record = verifiedPrNumber !== null
? { ...info, issueNumber: verifiedIssueNumber, prNumber: verifiedPrNumber }
: { issueNumber: verifiedIssueNumber, issueUrl: info.issueUrl, issueState: info.issueState }
entry.state.setTrackedWorkStatus(issueKey, record)
} }
} }
} }
@@ -966,7 +1050,6 @@ ${items.map((i) => `${i.key}: ${i.title}`).join('\n')}`
} }
} finally { } finally {
summariesInbox.delete(token) summariesInbox.delete(token)
if (projectsToken) projectsInbox.delete(projectsToken)
if (trackingToken) trackingInbox.delete(trackingToken) if (trackingToken) trackingInbox.delete(trackingToken)
if (relatedToken) relatedInbox.delete(relatedToken) if (relatedToken) relatedInbox.delete(relatedToken)
} }
@@ -979,18 +1062,6 @@ ${items.map((i) => `${i.key}: ${i.title}`).join('\n')}`
} }
} }
// Structured handoff for the registered-projects list. There's no direct SDK API
// to enumerate the user's app projects, so the model calls its own list_projects
// tool then hands the result back via submit_projects; the handler drops the list
// here keyed by a per-request token, and enrichPlainEnglish reads it once the turn
// settles. Enumeration is folded into the issue-triage round-trip (see above) so
// there's no separate "Loading your projects…" turn.
const projectsInbox = new Map() // token -> [{ id, name, repo, defaultBranch, path }]
function makeProjectsToken() {
return `proj_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
}
// Structured handoff for pre-existing GitHub tracking issues/PRs. The model runs // Structured handoff for pre-existing GitHub tracking issues/PRs. The model runs
// the label search + PR lookups in the folded triage round-trip and hands the // the label search + PR lookups in the folded triage round-trip and hands the
// result back via submit_tracking; enrichPlainEnglish reads it once the turn // result back via submit_tracking; enrichPlainEnglish reads it once the turn
@@ -1263,9 +1334,14 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// have filed where we asked. Empty unless a concrete "owner/repo" is known, in // have filed where we asked. Empty unless a concrete "owner/repo" is known, in
// which case URL checking is skipped (no anchor to compare against). // which case URL checking is skipped (no anchor to compare against).
const isGithubTracker = selectedTrackerConfig.id === 'github' const isGithubTracker = selectedTrackerConfig.id === 'github'
const expectedRepo = /^[^/\s]+\/[^/\s]+$/.test(String(issueRepo || '')) // The tracking ISSUE and the code PR can live in DIFFERENT repos, both derived
? String(issueRepo).toLowerCase() // OUTSIDE the model from trusted canvas state (see deriveRepoAnchors). `expectedRepo`
: '' // gates the outside-the-model URL enforcement for the tracking issue: only a
// well-formed "owner/repo" anchor can validate a model-reported issue URL, so a
// blank value resolves to '' (fails closed — no URL check, and blocked below).
// `prExpectedRepo` is the TRUSTED PR anchor: the cloud repo in Cloud mode, or the
// current checkout's repo in Local mode — never a model-relayed value.
const { expectedRepo, prExpectedRepo } = deriveRepoAnchors(prTargets, issueRepo, currentCheckoutRepo(runtimeDefaults))
// Trusted host the authorized issue/PR links must live on, paired with // Trusted host the authorized issue/PR links must live on, paired with
// `expectedRepo`. Repo alone is not enough: a look-alike host would otherwise // `expectedRepo`. Repo alone is not enough: a look-alike host would otherwise
// let a model-reported link pass the repo check (see urlInRepo). Sourced from // let a model-reported link pass the repo check (see urlInRepo). Sourced from
@@ -1279,18 +1355,26 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// action would write to GitHub. Without a trusted `expectedRepo` the // action would write to GitHub. Without a trusted `expectedRepo` the
// outside-the-model URL enforcement below has nothing to compare against and is // outside-the-model URL enforcement below has nothing to compare against and is
// skipped entirely — precisely the state injected Sentry text could exploit to // skipped entirely — precisely the state injected Sentry text could exploit to
// steer a write elsewhere. Every GitHub-writing path needs the anchor: the // steer a write elsewhere. The "Fix with Copilot" path ALWAYS opens a GitHub PR,
// "Fix with Copilot" path ALWAYS opens a GitHub PR, and the tracking-only path // so it needs a concrete PR-repo anchor; a GitHub tracker ALWAYS files an issue,
// files a GitHub issue whenever the GitHub tracker is selected. Only a // so it needs a concrete issue-repo anchor. Only a tracking-only run on a
// tracking-only run on a non-GitHub tracker (Linear/Jira) performs no GitHub // non-GitHub tracker (Linear/Jira) performs no GitHub write, so it may proceed
// write, so it may proceed without a repo anchor. Normal checkouts resolve a // without a repo anchor. Normal checkouts resolve a repo from the git remote;
// repo from the git remote; this trips only when detection fails or no target // this trips only when detection fails. The two failures have different fixes, so
// is configured — tell the user to set one rather than writing unverifiably. // report the one that actually bit: a missing ISSUE anchor means no target repo is
if (!expectedRepo && (wantsCopilot || isGithubTracker)) { // configured (fix in Settings), while a missing PR anchor is "Current project"
// running in a checkout with no detectable GitHub remote (fix: run from a
// repo-backed checkout, or use Cloud mode with a repo set in Settings).
const missingIssueRepo = isGithubTracker && !expectedRepo
const missingPrRepo = wantsCopilot && !prExpectedRepo
if (missingIssueRepo || missingPrRepo) {
const error = missingIssueRepo
? 'No target repository is configured, so issue creation cannot be verified. Set a target repository in Settings before starting work.'
: 'The pull request destination has no valid repository (owner/repo), so it cannot be verified. Open this canvas from a checkout with a GitHub remote, or use Cloud mode with a repository set in Settings, before starting work.'
for (const key of startableKeys) { for (const key of startableKeys) {
entry.notifyWork(key, { entry.notifyWork(key, {
phase: 'error', phase: 'error',
error: 'No target repository is configured, so issue/PR creation cannot be verified. Set a target repository in Settings before starting work.', error,
}) })
} }
return return
@@ -1337,13 +1421,14 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// Mint a token for THIS work item so the eventual PR callback can be routed // Mint a token for THIS work item so the eventual PR callback can be routed
// back to this exact instance+key even across canvases sharing a short key. // back to this exact instance+key even across canvases sharing a short key.
// Capture the repository authorized at hand-off time so a later PR callback is // Capture the repo the PR is authorized against AT hand-off time (frozen, not
// validated against THAT repo, not whatever the target settings happen to be // recomputed from live settings) so retargeting the canvas mid-flight can't
// when the callback lands — changing the target mid-flight must not cause a // reject a legitimate PR in the original repo or admit one in a newly-set repo.
// legitimate PR in the original repo to be rejected (or an attacker one in a // This anchor is '' only for non-GitHub trackers or a local checkout with no
// newly-set repo to pass). // detectable GitHub remote; submit_work_pr treats an empty anchor as "no URL
// gate" accordingly.
const workToken = randomUUID() const workToken = randomUUID()
workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: expectedRepo, authorizedHost: allowedHost }) workRegistry.set(workToken, { entry, key, scopeGen, authorizedRepo: prExpectedRepo, authorizedHost: allowedHost })
entry.notifyWork(key, { phase: 'working', error: '' }) entry.notifyWork(key, { phase: 'working', error: '' })
try { try {
@@ -1398,16 +1483,17 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// "already being worked on"; surface an unconfirmed-result error instead // "already being worked on"; surface an unconfirmed-result error instead
// so the user can retry. // so the user can retry.
const ACTIVE_PR_STATES = new Set(['open', 'draft']) const ACTIVE_PR_STATES = new Set(['open', 'draft'])
// A bare PR number is not proof the dedup work lives in the authorized // An authoritative "already being worked on" skip SUPPRESSES the trusted
// repo — the card would render "already being worked on · PR #N" as an // create_session flow and surfaces a PR link on the card, so it must rest on a
// authoritative claim, and the repo check below only covers URL-bearing // TRUSTED PR-repo anchor (Cloud mode: the configured repo; Local mode: the
// artifacts. When a concrete repo is set, require the confirming PR to // current checkout's repo — never a model-relayed value). Require the
// carry a URL (validated against expectedRepo just below); a number-only // confirming PR to carry a URL, validated against that anchor just below; a
// reference counts as no confirming PR and surfaces the retryable "no open // number-only reference, a missing anchor (a checkout with no detectable
// pull request" error rather than a spoofable skip. // remote), or a non-open state all count as no confirmed PR and surface a
const hasPrRef = expectedRepo // retryable error rather than a spoofable skip. The PR lives in the PR repo,
? Boolean(result.existingPrUrl) // which in local mode can differ from the issue repo, so anchor this guard on
: Boolean(result.existingPrNumber || result.existingPrUrl) // prExpectedRepo — not expectedRepo.
const hasPrRef = Boolean(prExpectedRepo) && Boolean(result.existingPrUrl)
const hasActivePr = hasPrRef && ACTIVE_PR_STATES.has(result.existingPrState) const hasActivePr = hasPrRef && ACTIVE_PR_STATES.has(result.existingPrState)
if (!hasActivePr) { if (!hasActivePr) {
workRegistry.delete(workToken) workRegistry.delete(workToken)
@@ -1415,7 +1501,7 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
phase: 'error', phase: 'error',
error: hasPrRef error: hasPrRef
? 'Agent reported this issue as already being worked on, but the referenced pull request is not open (it may be closed or merged). Retry to open fresh work.' ? 'Agent reported this issue as already being worked on, but the referenced pull request is not open (it may be closed or merged). Retry to open fresh work.'
: 'Agent reported this issue as already being worked on but returned no open pull request to confirm it.', : 'Agent reported this issue as already being worked on but returned no verifiable open pull request to confirm it.',
}) })
continue continue
} }
@@ -1423,29 +1509,44 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// Same outside-the-model repo enforcement for the dedup ("already being // Same outside-the-model repo enforcement for the dedup ("already being
// worked on") links. A spoofed skip — injected Sentry text claiming work // worked on") links. A spoofed skip — injected Sentry text claiming work
// already exists in an attacker-chosen repo — would otherwise surface a // already exists in an attacker-chosen repo — would otherwise surface a
// false "already being worked on" card linking off to that repo. // false "already being worked on" card linking off to that repo. Each URL
// `existingPrUrl` is always a GitHub PR, so it's checked whenever a concrete // is paired with the repo it must ACTUALLY live in: the issue URL with the
// anchor is known; `existingIssueUrl` only on the GitHub tracker (Linear/Jira // issue repo (`expectedRepo`, GitHub tracker only — Linear/Jira issue URLs
// issue URLs legitimately live elsewhere). '' fails closed. // legitimately live elsewhere), and the PR URL with the PR repo
if (expectedRepo) { // (`prExpectedRepo`). The PR anchor is guaranteed concrete here (the skip
const dedupArtifacts = [ // guard above requires it), so only the issue anchor can be empty — on a
...(isGithubTracker ? [['issue', result.existingIssueUrl]] : []), // non-GitHub tracker — in which case its check is skipped (failing closed for
['pull request', result.existingPrUrl], // it) instead of shadowing the PR.
].filter(([, url]) => url) const dedupArtifacts = [
const bad = dedupArtifacts.find(([, url]) => !urlInRepo(url, expectedRepo, allowedHost)) ...(isGithubTracker && expectedRepo ? [['issue', result.existingIssueUrl, expectedRepo, 'issue']] : []),
if (bad) { ['pull request', result.existingPrUrl, prExpectedRepo, 'pull'],
entry.notifyWork(key, { ].filter(([, url]) => url)
phase: 'error', const bad = dedupArtifacts.find(([, url, anchor, kind]) => !urlInRepo(url, anchor, allowedHost, kind))
error: `The ${bad[0]} the agent reported as existing work is not in the expected repository (${expectedRepo}). Nothing was trusted — retry.`, if (bad) {
}) entry.notifyWork(key, {
continue phase: 'error',
} error: `The ${bad[0]} the agent reported as existing work is not in the expected repository (${bad[2]}). Nothing was trusted — retry.`,
})
continue
} }
// Derive every DISPLAYED number from its own validated URL, never from the
// model-reported *Number field. urlInRepo above only proved each link's
// repo; a reply could still pair /pull/1 with existingPrNumber 999 and
// render "PR #999" linking to PR 1. repoRefNumber re-extracts the number from
// the same URL it validated and yields undefined for a missing/unverifiable
// URL, so no bare number is shown. The PR anchor is always concrete here; the
// issue anchor may be empty on a non-GitHub tracker, where there is nothing to
// validate against and the model value stands.
const skipIssueAnchored = isGithubTracker && Boolean(expectedRepo)
const skippedIssueNumber = skipIssueAnchored
? (repoRefNumber(result.existingIssueUrl, expectedRepo, allowedHost, 'issue') ?? undefined)
: result.existingIssueNumber
const skippedPrNumber = repoRefNumber(result.existingPrUrl, prExpectedRepo, allowedHost, 'pull') ?? undefined
entry.notifyWork(key, { entry.notifyWork(key, {
phase: 'skipped', phase: 'skipped',
existingIssueNumber: result.existingIssueNumber, existingIssueNumber: skippedIssueNumber,
existingIssueUrl: result.existingIssueUrl, existingIssueUrl: result.existingIssueUrl,
existingPrNumber: result.existingPrNumber, existingPrNumber: skippedPrNumber,
existingPrUrl: result.existingPrUrl, existingPrUrl: result.existingPrUrl,
existingPrState: result.existingPrState, existingPrState: result.existingPrState,
}) })
@@ -1510,50 +1611,69 @@ async function onWorkSelected(entry, issueKeys, modelByKey, assignCopilot) {
// Only a hand-off leaves work pending an out-of-band PR callback; anything // Only a hand-off leaves work pending an out-of-band PR callback; anything
// else is terminal here, so release its token immediately. // else is terminal here, so release its token immediately.
if (!handedOff) workRegistry.delete(workToken) if (!handedOff) workRegistry.delete(workToken)
// Enforcement boundary OUTSIDE the model: the target repository was fixed by // Enforcement boundary OUTSIDE the model: the target repositories were fixed
// the canvas (trusted `expectedRepo`), not by the model or the untrusted // by the canvas (trusted `expectedRepo` for the issue, `prExpectedRepo` for
// Sentry text folded into the prompt. Independently re-derive the repo from // the code PR), not by the model or the untrusted Sentry text folded into the
// every artifact URL the turn reported and reject the whole result if any // prompt. Independently re-derive the repo from every artifact URL the turn
// doesn't live in `expectedRepo` — a mismatch means the turn filed somewhere // reported and reject the whole result if any doesn't live in its authorized
// we never authorized (a model slip, or injected data steering it elsewhere), // repo — a mismatch means the turn filed somewhere we never authorized (a
// so we must not surface or persist those links. A false from urlInRepo // model slip, or injected data steering it elsewhere), so we must not surface
// ("cannot confirm": wrong host, non-GitHub host, wrong repo, or wrong // or persist those links. A false from urlInRepo ("cannot confirm": wrong
// shape) also fails closed. A code PR is ALWAYS a GitHub artifact, so // host, non-GitHub host, wrong repo, or wrong shape) also fails closed. An
// `prUrl` is validated whenever a concrete anchor is known, independent of // issue URL is only a GitHub artifact on the GitHub tracker — Linear/Jira
// the issue tracker. An issue URL is only GitHub on the GitHub tracker — // issue URLs legitimately live elsewhere — so `issueUrl` is validated only
// Linear/Jira issue URLs legitimately live elsewhere — so `issueUrl` is // then, against the issue repo. A code PR is ALWAYS a GitHub artifact but
// validated only then. The placeholder-repo case (no concrete // lands in the PR repo (which in local mode may differ from the issue repo),
// `expectedRepo`) has no anchor to compare against. // so `prUrl` is validated against `prExpectedRepo` whenever a concrete PR
if (expectedRepo) { // anchor is known. The placeholder-repo case (no concrete anchor) has nothing
const artifacts = [ // to compare against.
...(isGithubTracker ? [['issue', result.issueUrl]] : []), if (isGithubTracker && expectedRepo && result.issueUrl && !urlInRepo(result.issueUrl, expectedRepo, allowedHost, 'issue')) {
['pull request', result.prUrl], workRegistry.delete(workToken)
].filter(([, url]) => url) entry.notifyWork(key, {
const bad = artifacts.find(([, url]) => !urlInRepo(url, expectedRepo, allowedHost)) phase: 'error',
if (bad) { error: `The issue the agent reported is not in the expected repository (${expectedRepo}). Nothing was trusted — retry.`,
workRegistry.delete(workToken) })
entry.notifyWork(key, { continue
phase: 'error', }
error: `The ${bad[0]} the agent reported is not in the expected repository (${expectedRepo}). Nothing was trusted — retry.`, if (prExpectedRepo && result.prUrl && !urlInRepo(result.prUrl, prExpectedRepo, allowedHost, 'pull')) {
}) workRegistry.delete(workToken)
continue entry.notifyWork(key, {
} phase: 'error',
error: `The pull request the agent reported is not in the expected repository (${prExpectedRepo}). Nothing was trusted — retry.`,
})
continue
} }
// A fast spawned session can fire submit_work_pr BEFORE this parent turn // A fast spawned session can fire submit_work_pr BEFORE this parent turn
// settles, merging real PR fields into state. This terminal patch must not // settles, merging real PR fields into state. This terminal patch must not
// clobber that: setWorkStatus shallow-merges, so an explicit `undefined` // clobber that: setWorkStatus shallow-merges, so an explicit `undefined`
// here would erase the callback's link. Include only fields we actually have. // here would erase the callback's link. Include only fields we actually have.
const patch = { phase: handedOff ? 'handed-off' : 'done' } const patch = { phase: handedOff ? 'handed-off' : 'done' }
if (result.issueNumber != null) patch.issueNumber = result.issueNumber // Derive both displayed numbers from their validated URLs, not the
// model-reported *Number fields: the URL checks above proved each link's
// repo, but a reply could still pair /pull/1 with prNumber 999 and render
// "PR #999" linking to PR 1 (same for the issue). With a concrete GitHub
// anchor, repoRefNumber re-extracts the number from the same URL it validated
// (null → drop, so a bare unverifiable number is never surfaced). With no
// concrete anchor there's nothing to validate against, so the model number
// stands. Each field is assigned only when present so this terminal patch —
// shallow-merged by setWorkStatus — never overwrites a live submit_work_pr
// callback's link with undefined.
const doneIssueNumber = (isGithubTracker && expectedRepo)
? repoRefNumber(result.issueUrl, expectedRepo, allowedHost, 'issue')
: (result.issueNumber != null ? result.issueNumber : null)
if (doneIssueNumber != null) patch.issueNumber = doneIssueNumber
if (result.issueUrl) patch.issueUrl = result.issueUrl if (result.issueUrl) patch.issueUrl = result.issueUrl
// A PR is always a GitHub artifact. With a concrete repo, only surface its // A PR is always a GitHub artifact. With a concrete repo, surface only the
// number when a URL proved (in the block above) it lives in expectedRepo — // number parsed from a URL that proved (above) it lives in prExpectedRepo —
// otherwise a bare, unverifiable "PR #N" could render as authoritative // otherwise a bare, unverifiable "PR #N" could render as authoritative
// success. Without a concrete repo there's nothing to validate against, so a // success. Without a concrete repo there's nothing to validate against, so the
// number still shows. The real PR for a hand-off arrives later via the // model number still shows. The real PR for a hand-off arrives later via the
// validated submit_work_pr callback, so dropping an unverifiable number here // validated submit_work_pr callback, so dropping an unverifiable number here
// loses nothing. // loses nothing.
if (result.prNumber != null && (result.prUrl || !expectedRepo)) patch.prNumber = result.prNumber const donePrNumber = prExpectedRepo
? repoRefNumber(result.prUrl, prExpectedRepo, allowedHost, 'pull')
: (result.prNumber != null ? result.prNumber : null)
if (donePrNumber != null) patch.prNumber = donePrNumber
if (result.prUrl) patch.prUrl = result.prUrl if (result.prUrl) patch.prUrl = result.prUrl
if (result.sessionId) patch.sessionId = result.sessionId if (result.sessionId) patch.sessionId = result.sessionId
if (result.sessionName) patch.sessionName = result.sessionName if (result.sessionName) patch.sessionName = result.sessionName
@@ -1644,42 +1764,6 @@ const session = await joinSession({
return `Recorded ${count} issue ${count === 1 ? 'summary' : 'summaries'}.` return `Recorded ${count} issue ${count === 1 ? 'summary' : 'summaries'}.`
}, },
}, },
{
name: 'submit_projects',
description:
'Submit the list of the user\'s registered app projects for the Sentry Triage canvas Local target dropdown. Call this exactly once after list_projects; pass back the token from the request unchanged.',
parameters: {
type: 'object',
properties: {
token: {
type: 'string',
description: 'The exact token given in the request. Pass it back unchanged.',
},
projects: {
type: 'array',
description: 'The registered app projects.',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'The project id.' },
name: { type: 'string', description: 'The project display name.' },
repo: { type: 'string', description: 'The github repo (owner/name), if any.' },
defaultBranch: { type: 'string', description: 'The project default branch, if any.' },
path: { type: 'string', description: 'The local main_repo_path, if any.' },
},
required: ['id'],
},
},
},
required: ['token', 'projects'],
},
handler: async (args) => {
const token = typeof args?.token === 'string' ? args.token : ''
const projects = Array.isArray(args?.projects) ? args.projects : []
if (token) projectsInbox.set(token, projects)
return `Recorded ${projects.length} project${projects.length === 1 ? '' : 's'}.`
},
},
{ {
name: 'submit_tracking', name: 'submit_tracking',
description: description:
@@ -1789,7 +1873,7 @@ const session = await joinSession({
// 'handed-off' with a dead ("#") link — unrecoverable. Rejecting here // 'handed-off' with a dead ("#") link — unrecoverable. Rejecting here
// leaves the token intact so the session can retry with a real URL. // leaves the token intact so the session can retry with a real URL.
const prUrl = safeSentryUrl(args?.prUrl) const prUrl = safeSentryUrl(args?.prUrl)
const prNumber = Number.isFinite(Number(args?.prNumber)) ? Number(args.prNumber) : undefined const reportedPrNumber = Number.isFinite(Number(args?.prNumber)) ? Number(args.prNumber) : undefined
const prState = typeof args?.prState === 'string' && args.prState.trim() ? args.prState.trim().toLowerCase() : 'draft' const prState = typeof args?.prState === 'string' && args.prState.trim() ? args.prState.trim().toLowerCase() : 'draft'
if (!key || !prUrl) return 'Ignored: a Sentry issue key and a valid http(s) prUrl are both required.' if (!key || !prUrl) return 'Ignored: a Sentry issue key and a valid http(s) prUrl are both required.'
if (!workToken) return 'Ignored: a workToken is required — pass the token from the hand-off prompt unchanged.' if (!workToken) return 'Ignored: a workToken is required — pass the token from the hand-off prompt unchanged.'
@@ -1799,7 +1883,7 @@ const session = await joinSession({
// would keep that phase through the shallow merge and the PR link — gated // would keep that phase through the shallow merge and the PR link — gated
// on 'done'/'handed-off' by the renderer — would never show. // on 'done'/'handed-off' by the renderer — would never show.
const patch = { phase: 'handed-off', prUrl, prState } const patch = { phase: 'handed-off', prUrl, prState }
if (prNumber !== undefined) patch.prNumber = prNumber // patch.prNumber is added after the repo check below, derived from prUrl.
// The token identifies the exact instance + issue that started this work // The token identifies the exact instance + issue that started this work
// and is single-use. Consume it so a replayed call can't double-apply, // and is single-use. Consume it so a replayed call can't double-apply,
// and reject an unknown/expired token instead of falling back to // and reject an unknown/expired token instead of falling back to
@@ -1814,20 +1898,33 @@ const session = await joinSession({
if (registered.key !== key) { if (registered.key !== key) {
return `Ignored: workToken does not match key ${key} (it was issued for ${registered.key}).` return `Ignored: workToken does not match key ${key} (it was issued for ${registered.key}).`
} }
// Same outside-the-model enforcement as the success gate: this PR must live // Same outside-the-model enforcement as the success gate: when the work item
// in the repository the work item was authorized against WHEN IT STARTED — // was authorized against a concrete repo WHEN IT STARTED (captured in the
// captured in the token, not recomputed from current settings, so retargeting // token, not recomputed from current settings), this PR must live there, so
// the canvas mid-flight can't reject a legitimate PR in the original repo (or // retargeting the canvas mid-flight can't reject a legitimate PR in the
// admit an attacker one in a newly-set repo). A code PR is always on GitHub, // original repo or admit an attacker one in a newly-set repo. Reject WITHOUT
// so this applies regardless of the issue tracker. Reject WITHOUT consuming // consuming the single-use token so a genuine session can retry the URL.
// the single-use token so a genuine session can retry with the correct URL. // The authorized repo is '' for non-GitHub trackers or a local checkout with
// '' ("cannot confirm") fails closed; the authorized repo is always concrete // no detectable GitHub remote; in those cases there is no trusted URL anchor,
// here because onWorkSelected refuses to start GitHub work without one. // so the gate below is skipped and the reported number is shown as-is. When a
// concrete anchor IS present (cloud / current-project), '' from repoRefNumber
// fails closed.
const regRepo = registered.authorizedRepo || '' const regRepo = registered.authorizedRepo || ''
const regHost = registered.authorizedHost || '' const regHost = registered.authorizedHost || ''
if (regRepo && !urlInRepo(prUrl, regRepo, regHost)) { // Derive the DISPLAYED PR number from the same validated URL, never from the
// model-reported prNumber: a session could pair /pull/1 with prNumber 999 and
// render "PR #999" linking to PR 1. repoRefNumber returns null on any
// host/repo/shape mismatch (the reject below, equivalent to the old urlInRepo
// guard) and otherwise the number parsed from the URL. When there is no
// concrete authorized repo there's nothing to validate against, so fall back
// to the reported number (display only — no authorization rides on it).
const verifiedPrNumber = regRepo
? repoRefNumber(prUrl, regRepo, regHost, 'pull')
: (reportedPrNumber ?? null)
if (regRepo && verifiedPrNumber === null) {
return `Ignored: the reported PR for ${key} is not in the expected repository (${regRepo}).` return `Ignored: the reported PR for ${key} is not in the expected repository (${regRepo}).`
} }
if (verifiedPrNumber != null) patch.prNumber = verifiedPrNumber
workRegistry.delete(workToken) workRegistry.delete(workToken)
// Reject a callback whose originating org/scope is no longer current, so a // Reject a callback whose originating org/scope is no longer current, so a
// late PR link can't land on a different org's board (short keys collide // late PR link can't land on a different org's board (short keys collide
@@ -1836,7 +1933,7 @@ const session = await joinSession({
return `Ignored: ${registered.key} belongs to a previous org/scope that is no longer active.` return `Ignored: ${registered.key} belongs to a previous org/scope that is no longer active.`
} }
registered.entry.notifyWork(registered.key, patch) registered.entry.notifyWork(registered.key, patch)
return `Updated ${registered.key} with PR #${prNumber ?? '?'} (${prState}).` return `Updated ${registered.key} with PR #${verifiedPrNumber ?? '?'} (${prState}).`
}, },
}, },
], ],
+8 -4
View File
@@ -104,7 +104,6 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
orgDefault: state.getOrgDefault(), orgDefault: state.getOrgDefault(),
savedDefaultOrg: state.getSavedDefaultOrg(), savedDefaultOrg: state.getSavedDefaultOrg(),
project: state.getProject(), project: state.getProject(),
projectOptions: state.getProjectOptions(),
period: state.getPeriod(), period: state.getPeriod(),
periods: PERIODS, periods: PERIODS,
projects: state.getProjects(), projects: state.getProjects(),
@@ -362,14 +361,17 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
const payload = parseJson(body) const payload = parseJson(body)
const current = state.getPrTargets() const current = state.getPrTargets()
const pick = (value, fallback) => (typeof value === 'string' ? value : fallback) const pick = (value, fallback) => (typeof value === 'string' ? value : fallback)
// The local fix-session hand-off always runs in the CURRENT project (the
// canvas's own checkout, host-trusted from its git remote), so there is no
// model-relayed project selection to bind here. Cross-repo work uses Cloud
// mode, whose repo the user types directly in Settings (also trusted). Only
// the local path/branch and cloud repo/branch are accepted from the payload.
const next = { const next = {
mode: pick(payload.mode, current.mode), mode: pick(payload.mode, current.mode),
model: pick(payload.model, current.model), model: pick(payload.model, current.model),
local: { local: {
path: pick(payload.localPath, current.local.path), path: pick(payload.localPath, current.local.path),
baseBranch: pick(payload.localBranch, current.local.baseBranch), baseBranch: pick(payload.localBranch, current.local.baseBranch),
projectId: pick(payload.localProjectId, current.local.projectId),
projectName: pick(payload.localProjectName, current.local.projectName),
}, },
cloud: { cloud: {
repo: pick(payload.cloudRepo, current.cloud.repo), repo: pick(payload.cloudRepo, current.cloud.repo),
@@ -384,7 +386,9 @@ export function startServer({ port = 0, onRefresh, onAction, onWorkSelected, onR
// the stale annotations now, invalidate any pending enrichment, and // the stale annotations now, invalidate any pending enrichment, and
// re-derive against the new repo. Model/base-branch-only edits keep them. // re-derive against the new repo. Model/base-branch-only edits keep them.
const repoId = (t) => const repoId = (t) =>
[t.cloud.repo, t.local.path, t.local.projectId].map((v) => (v || '').trim()).join('\u0000') [t.mode, t.cloud.repo, t.local.path]
.map((v) => (v || '').trim())
.join('\u0000')
const repoChanged = repoId(next) !== repoId(current) const repoChanged = repoId(next) !== repoId(current)
state.setPrTargets(next) state.setPrTargets(next)
if (repoChanged) { if (repoChanged) {
+1 -29
View File
@@ -52,11 +52,6 @@ export function createState() {
// SSE project broadcast to the right org (setup screen switches org before any // SSE project broadcast to the right org (setup screen switches org before any
// scan, so the panel's own org isn't a reliable signal). // scan, so the panel's own org isn't a reliable signal).
let projectsOrg = '' let projectsOrg = ''
// Registered app projects the "Work on selected" local hand-off can spawn the
// fix session in. Enumerated eagerly on canvas open via the agent's
// list_projects tool (there is no direct SDK API), each entry is
// { id, name, repo, defaultBranch, path }.
let projectOptions = []
// Sentry search window. Defaults to the last day; the user can widen it from // Sentry search window. Defaults to the last day; the user can widen it from
// the issues list to look further back. Only Sentry's supported periods are // the issues list to look further back. Only Sentry's supported periods are
// accepted (see PERIODS below). // accepted (see PERIODS below).
@@ -99,7 +94,7 @@ export function createState() {
const prTargets = { const prTargets = {
mode: 'local', mode: 'local',
model: '', model: '',
local: { path: '', baseBranch: '', projectId: '', projectName: '' }, local: { path: '', baseBranch: '' },
cloud: { repo: '', baseBranch: '' }, cloud: { repo: '', baseBranch: '' },
} }
@@ -114,8 +109,6 @@ export function createState() {
local: { local: {
path: str(local.path), path: str(local.path),
baseBranch: str(local.baseBranch), baseBranch: str(local.baseBranch),
projectId: str(local.projectId),
projectName: str(local.projectName),
}, },
cloud: { cloud: {
repo: str(cloud.repo), repo: str(cloud.repo),
@@ -252,25 +245,6 @@ export function createState() {
return projectsOrg return projectsOrg
}, },
getProjectOptions() {
return projectOptions
},
setProjectOptions(list) {
projectOptions = Array.isArray(list)
? list
.filter((p) => p && typeof p === 'object' && typeof p.id === 'string' && p.id)
.map((p) => ({
id: p.id,
name: typeof p.name === 'string' && p.name ? p.name : p.id,
repo: typeof p.repo === 'string' ? p.repo : '',
defaultBranch: typeof p.defaultBranch === 'string' ? p.defaultBranch : '',
path: typeof p.path === 'string' ? p.path : '',
}))
: []
return projectOptions
},
getConnections() { getConnections() {
return connections return connections
}, },
@@ -335,8 +309,6 @@ export function createState() {
prTargets.model = normalized.model prTargets.model = normalized.model
prTargets.local.path = normalized.local.path prTargets.local.path = normalized.local.path
prTargets.local.baseBranch = normalized.local.baseBranch prTargets.local.baseBranch = normalized.local.baseBranch
prTargets.local.projectId = normalized.local.projectId
prTargets.local.projectName = normalized.local.projectName
prTargets.cloud.repo = normalized.cloud.repo prTargets.cloud.repo = normalized.cloud.repo
prTargets.cloud.baseBranch = normalized.cloud.baseBranch prTargets.cloud.baseBranch = normalized.cloud.baseBranch
return prTargets return prTargets