@cat-factory/app 0.208.2 → 0.210.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,272 @@
1
+ <script setup lang="ts">
2
+ // Capability credentials — the sealed, per-workspace values behind the secrets a registered tool
3
+ // server (MCP) or generative binary integration declares BY NAME. It renders as a CHECKLIST, not
4
+ // a blank key-value form: which keys exist is a property of the deployment's CODE, so the panel
5
+ // projects the declarations and the operator fills them in. A blank form would mean reading the
6
+ // deployment's source to learn what to type, with a typo producing a sealed row nothing asks for.
7
+ //
8
+ // Values are write-only and saved ONE KEY AT A TIME. The whole-set write is unusable here: this
9
+ // client never receives the values, so replacing the set would delete every credential the
10
+ // operator did not retype in this sitting.
11
+ //
12
+ // Renders inside the Infrastructure window's "Capability credentials" tab: what an agent's tools
13
+ // authenticate as is part of where agents RUN. `secrets.manage`-gated end to end (the READ
14
+ // included — the view names the deployment's credential keys), so the tab is HIDDEN, never
15
+ // disabled, for anyone without it.
16
+ import { computed, onMounted, reactive, ref } from 'vue'
17
+ import type { CapabilityCredentialStatus } from '~/types/capabilityCredentials'
18
+ import SecretInput from '~/components/common/SecretInput.vue'
19
+
20
+ const { t, d } = useI18n()
21
+ const store = useCapabilityCredentialsStore()
22
+ const toast = useToast()
23
+ const { confirmAction, toastDone } = useConfirmAction()
24
+
25
+ // Which declaring capability wants a key. An exhaustive Record over the wire union, so a new
26
+ // subject fails to compile until it has translated copy — the sanctioned guard for an enum-keyed
27
+ // lookup the typed-message-key check cannot see.
28
+ type CredentialSubject = CapabilityCredentialStatus['declaredBy'][number]['subject']
29
+ const SUBJECT_LABELS = computed<Record<CredentialSubject, string>>(() => ({
30
+ 'tool-server': t('settings.capabilityCredentials.subject.toolServer'),
31
+ 'binary-generator': t('settings.capabilityCredentials.subject.binaryGenerator'),
32
+ }))
33
+
34
+ // Draft values, keyed by credential key. Never prefilled: nothing here was ever read back, and a
35
+ // masked placeholder standing in for a stored value would make "unchanged" and "retyped" look
36
+ // identical at the save button.
37
+ const drafts = reactive<Record<string, string>>({})
38
+ // Which row's WHICH button is in flight. The action is part of the state because save and delete
39
+ // sit beside each other on one row: a shared per-key flag would spin the delete button for the
40
+ // save the user just clicked, which reads as a delete in progress.
41
+ const busy = ref<{ key: string; action: 'save' | 'remove' } | null>(null)
42
+ // Failures present through the shared status-class funnel (translated description up front, the
43
+ // raw backend prose + requestId behind "Show details"), never raw `e.message` as the description.
44
+ const { present } = usePipelineErrorToast()
45
+
46
+ const view = computed(() => store.view)
47
+
48
+ function isBusy(key: string, action: 'save' | 'remove') {
49
+ return busy.value?.key === key && busy.value.action === action
50
+ }
51
+
52
+ // The tab this renders in only exists once the window's probe resolved `available === true`, so
53
+ // `ensureLoaded()` here would early-return every time and this error branch would be dead code.
54
+ // Read outright instead: the window owns the PROBE (a failure there means no tab), the panel owns
55
+ // the DATA (a failure here means the reader is looking at a list we could not fetch, and must be
56
+ // told). It also drops the staleness `ensureLoaded` carried, so reopening the tab after the
57
+ // deployment registered a new capability shows the new key.
58
+ onMounted(async () => {
59
+ try {
60
+ await store.load()
61
+ } catch (e) {
62
+ present(e, 'settings.capabilityCredentials.toast.loadFailed')
63
+ }
64
+ })
65
+
66
+ async function saveKey(key: string) {
67
+ const value = (drafts[key] ?? '').trim()
68
+ if (!value) return
69
+ busy.value = { key, action: 'save' }
70
+ try {
71
+ await store.save(key, value)
72
+ drafts[key] = ''
73
+ toast.add({
74
+ title: t('settings.capabilityCredentials.toast.saved', { key }),
75
+ icon: 'i-lucide-check',
76
+ color: 'success',
77
+ })
78
+ } catch (e) {
79
+ present(e, 'settings.capabilityCredentials.toast.saveFailed')
80
+ } finally {
81
+ busy.value = null
82
+ }
83
+ }
84
+
85
+ async function removeKey(key: string) {
86
+ const noun = t('settings.capabilityCredentials.credentialNoun', { key })
87
+ if (!(await confirmAction('remove', noun))) return
88
+ busy.value = { key, action: 'remove' }
89
+ try {
90
+ await store.remove(key)
91
+ toastDone('remove', noun)
92
+ } catch (e) {
93
+ present(e, 'settings.capabilityCredentials.toast.removeFailed')
94
+ } finally {
95
+ busy.value = null
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <template>
101
+ <div class="space-y-4" data-testid="capability-credentials-panel">
102
+ <p class="text-sm text-slate-400">
103
+ {{ t('settings.capabilityCredentials.intro') }}
104
+ </p>
105
+
106
+ <!-- The declaration read failed (the deployment's generative integrations could not be
107
+ reached), so this checklist may be SHORT and the orphan list is withheld. Said out loud
108
+ rather than rendered as a clean empty list: an outage and "nothing needs a credential"
109
+ are the same list and opposite facts. -->
110
+ <UAlert
111
+ v-if="view?.declarationsIncomplete"
112
+ color="warning"
113
+ variant="subtle"
114
+ icon="i-lucide-triangle-alert"
115
+ :title="t('settings.capabilityCredentials.incomplete.title')"
116
+ :description="t('settings.capabilityCredentials.incomplete.body')"
117
+ data-testid="capability-credentials-incomplete"
118
+ />
119
+
120
+ <section
121
+ v-for="entry in view?.declared ?? []"
122
+ :key="entry.key"
123
+ class="space-y-3 rounded-lg border border-slate-700 p-3"
124
+ :data-testid="`capability-credential-${entry.key}`"
125
+ >
126
+ <div class="flex flex-wrap items-center gap-2">
127
+ <code class="font-mono text-sm font-medium">{{ entry.key }}</code>
128
+ <UBadge v-if="entry.required" color="warning" variant="soft" size="sm">
129
+ {{ t('settings.capabilityCredentials.required') }}
130
+ </UBadge>
131
+ <UBadge v-else color="neutral" variant="soft" size="sm">
132
+ {{ t('settings.capabilityCredentials.optional') }}
133
+ </UBadge>
134
+ <UBadge
135
+ v-if="entry.stored"
136
+ color="success"
137
+ variant="soft"
138
+ size="sm"
139
+ :data-testid="`capability-credential-stored-${entry.key}`"
140
+ >
141
+ {{ t('settings.capabilityCredentials.stored') }}
142
+ </UBadge>
143
+ </div>
144
+
145
+ <!-- Who wants the value, so an operator can tell what they are about to change. One key is
146
+ routinely wanted by more than one capability (two integrations behind one vendor
147
+ account), which is exactly when a rotation has consequences beyond the row it edits. -->
148
+ <ul class="space-y-1 text-xs text-slate-400">
149
+ <li v-for="declarer in entry.declaredBy" :key="`${declarer.subject}:${declarer.id}`">
150
+ <span class="text-slate-300">{{ declarer.label }}</span>
151
+ <span class="text-slate-500"> · {{ SUBJECT_LABELS[declarer.subject] }}</span>
152
+ <span v-if="declarer.usage" class="block text-slate-500">{{ declarer.usage }}</span>
153
+ </li>
154
+ </ul>
155
+
156
+ <p v-if="entry.stored && entry.updatedAt" class="text-[11px] text-slate-500">
157
+ {{
158
+ t('settings.capabilityCredentials.storedAt', {
159
+ date: d(new Date(entry.updatedAt), 'short'),
160
+ })
161
+ }}
162
+ </p>
163
+ <!-- An EMPTY row is not the same fact in every deployment, so it must not read the same way.
164
+ Three states, and the backend reports them off the chain it actually composed: with the
165
+ fallback the deployment's own environment may still answer, and calling that "missing"
166
+ would send an operator hunting for a value that is already resolving; without it, blank
167
+ really does mean the capability cannot authenticate.
168
+
169
+ The third is that the chain cannot be described, and this copy says exactly that and
170
+ stops. It must not name a cause: a deployment's own resolver replacing the chain is the
171
+ usual one, but a facade that wired the store and dropped the flag lands here too, and
172
+ blaming a custom resolver would make that wiring bug read as a deliberate configuration
173
+ and send the operator to the one place that cannot explain it. -->
174
+ <p v-else-if="view?.environmentFallback === true" class="text-[11px] text-slate-500">
175
+ {{ t('settings.capabilityCredentials.notStoredWithFallback') }}
176
+ </p>
177
+ <p v-else-if="view?.environmentFallback === false" class="text-[11px] text-amber-400">
178
+ {{ t('settings.capabilityCredentials.notStored') }}
179
+ </p>
180
+ <p v-else class="text-[11px] text-slate-500">
181
+ {{ t('settings.capabilityCredentials.notStoredUnknownFallback') }}
182
+ </p>
183
+
184
+ <div class="flex items-end gap-2">
185
+ <UFormField
186
+ class="flex-1"
187
+ :label="
188
+ entry.stored
189
+ ? t('settings.capabilityCredentials.replaceValue')
190
+ : t('settings.capabilityCredentials.setValue')
191
+ "
192
+ >
193
+ <SecretInput
194
+ v-model="drafts[entry.key]"
195
+ class="w-full"
196
+ :data-testid="`capability-credential-input-${entry.key}`"
197
+ @keyup.enter="saveKey(entry.key)"
198
+ />
199
+ </UFormField>
200
+ <UButton
201
+ :loading="isBusy(entry.key, 'save')"
202
+ :disabled="!(drafts[entry.key] ?? '').trim() || isBusy(entry.key, 'remove')"
203
+ :data-testid="`capability-credential-save-${entry.key}`"
204
+ @click="saveKey(entry.key)"
205
+ >
206
+ {{ t('settings.capabilityCredentials.save') }}
207
+ </UButton>
208
+ <UButton
209
+ v-if="entry.stored"
210
+ color="error"
211
+ variant="ghost"
212
+ icon="i-lucide-trash-2"
213
+ :loading="isBusy(entry.key, 'remove')"
214
+ :disabled="isBusy(entry.key, 'save')"
215
+ :data-testid="`capability-credential-delete-${entry.key}`"
216
+ :aria-label="t('settings.capabilityCredentials.remove')"
217
+ @click="removeKey(entry.key)"
218
+ />
219
+ </div>
220
+ </section>
221
+
222
+ <p
223
+ v-if="view && !view.declared.length && !view.declarationsIncomplete"
224
+ class="text-sm text-slate-500"
225
+ >
226
+ {{ t('settings.capabilityCredentials.noneDeclared') }}
227
+ </p>
228
+
229
+ <!-- Stored keys nothing declares any more: a live secret nobody will ever ask for, which is
230
+ what a retired integration or a renamed variable leaves behind. Listed rather than
231
+ filtered, because only the operator can tell "delete this" from "the deployment
232
+ regressed". Withheld entirely while the declaration read is incomplete. -->
233
+ <section
234
+ v-if="view?.orphaned.length"
235
+ class="space-y-2 rounded-lg border border-amber-900/60 p-3"
236
+ data-testid="capability-credentials-orphaned"
237
+ >
238
+ <h3 class="text-sm font-semibold">
239
+ {{ t('settings.capabilityCredentials.orphaned.heading') }}
240
+ </h3>
241
+ <p class="text-xs text-slate-400">
242
+ {{ t('settings.capabilityCredentials.orphaned.body') }}
243
+ </p>
244
+ <div
245
+ v-for="orphan in view.orphaned"
246
+ :key="orphan.key"
247
+ class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
248
+ >
249
+ <div class="min-w-0">
250
+ <code class="font-mono text-sm">{{ orphan.key }}</code>
251
+ <span class="block text-[11px] text-slate-500">
252
+ {{
253
+ t('settings.capabilityCredentials.storedAt', {
254
+ date: d(new Date(orphan.updatedAt), 'short'),
255
+ })
256
+ }}
257
+ </span>
258
+ </div>
259
+ <UButton
260
+ color="error"
261
+ variant="ghost"
262
+ icon="i-lucide-trash-2"
263
+ size="sm"
264
+ :loading="isBusy(orphan.key, 'remove')"
265
+ :data-testid="`capability-credential-delete-${orphan.key}`"
266
+ :aria-label="t('settings.capabilityCredentials.remove')"
267
+ @click="removeKey(orphan.key)"
268
+ />
269
+ </div>
270
+ </section>
271
+ </div>
272
+ </template>
@@ -5,7 +5,12 @@ import {
5
5
  repinInfrastructureTab,
6
6
  } from './InfrastructureWindow.logic'
7
7
 
8
- const NONE = { agents: false, environments: false, packageRegistries: false }
8
+ const NONE = {
9
+ agents: false,
10
+ environments: false,
11
+ packageRegistries: false,
12
+ capabilityCredentials: false,
13
+ }
9
14
 
10
15
  describe('infrastructureTabs', () => {
11
16
  it('shows nothing when no probe reports a backend', () => {
@@ -28,10 +33,30 @@ describe('infrastructureTabs', () => {
28
33
  expect(infrastructureTabs({ ...NONE, agents: true })).toEqual(['runner-pool'])
29
34
  })
30
35
 
36
+ it('gates the capability-credentials tab on its own two-part probe', () => {
37
+ // Unlike its neighbours this one also gates on CONTENT: the panel is a checklist projected
38
+ // from the deployment's registered capabilities, so a build that registers none has no
39
+ // credential to type. The window folds that (and the `secrets.manage` check) into the flag.
40
+ expect(infrastructureTabs({ ...NONE, capabilityCredentials: true })).toEqual([
41
+ 'capability-credentials',
42
+ ])
43
+ })
44
+
31
45
  it('orders tabs by the question they answer, not by which probe resolved', () => {
32
46
  expect(
33
- infrastructureTabs({ agents: true, environments: true, packageRegistries: true }),
34
- ).toEqual(['runner-pool', 'environment', 'shared-stacks', 'package-registries'])
47
+ infrastructureTabs({
48
+ agents: true,
49
+ environments: true,
50
+ packageRegistries: true,
51
+ capabilityCredentials: true,
52
+ }),
53
+ ).toEqual([
54
+ 'runner-pool',
55
+ 'environment',
56
+ 'shared-stacks',
57
+ 'package-registries',
58
+ 'capability-credentials',
59
+ ])
35
60
  })
36
61
  })
37
62
 
@@ -19,12 +19,25 @@ export interface InfrastructureTabAvailability {
19
19
  environments: boolean
20
20
  /** The package-registries module answered its probe affirmatively (it 503s unconfigured). */
21
21
  packageRegistries: boolean
22
+ /**
23
+ * The capability-credential surface has something to show: its probe resolved (the module 503s
24
+ * with no encryption key, and 403s for a caller without `secrets.manage`) AND this deployment's
25
+ * registered capabilities declare a credential, or the workspace stored one nothing declares,
26
+ * or the declaration read failed.
27
+ *
28
+ * Unlike every other tab this one gates on CONTENT as well as availability, because the panel
29
+ * is a CHECKLIST projected from the deployment's code: a build that registers no tool server
30
+ * and no generative integration has no credential to type, so the tab would be a dead end. The
31
+ * failed-read case is deliberately kept IN — an unreadable list and an empty one are the same
32
+ * list and opposite facts, and only the panel can say which one this is.
33
+ */
34
+ capabilityCredentials: boolean
22
35
  }
23
36
 
24
37
  /**
25
38
  * The window's tabs, in display order. Order is the reading order of the questions they answer:
26
39
  * where agent containers run, where test environments run, what those environments attach to,
27
- * and what a checkout may install from.
40
+ * what a checkout may install from, and what the tools an agent reaches authenticate as.
28
41
  *
29
42
  * Shared stacks ride the test-environment probe because a stack is infra an environment attaches
30
43
  * to — there is nothing to attach without an environment backend.
@@ -34,6 +47,7 @@ export function infrastructureTabs(available: InfrastructureTabAvailability): In
34
47
  if (available.agents) tabs.push('runner-pool')
35
48
  if (available.environments) tabs.push('environment', 'shared-stacks')
36
49
  if (available.packageRegistries) tabs.push('package-registries')
50
+ if (available.capabilityCredentials) tabs.push('capability-credentials')
37
51
  return tabs
38
52
  }
39
53
 
@@ -15,6 +15,10 @@
15
15
  // - "Package registries" — the private npm registries a checkout installs from (formerly an
16
16
  // Integrations-hub row). What a container can resolve its dependencies from is part of the
17
17
  // execution environment, not an optional external system a workspace links in.
18
+ // - "Capability credentials" — the sealed per-workspace values behind the secrets a registered
19
+ // tool server (MCP) or generative binary integration declares. What an agent's tools
20
+ // authenticate as belongs beside where those agents run, and it is `secrets.manage`-only, so
21
+ // the tab is HIDDEN rather than disabled for anyone without that permission.
18
22
  // Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
19
23
  // backend integration is disabled (503) simply doesn't render.
20
24
  import { computed, ref, watch } from 'vue'
@@ -31,12 +35,15 @@ import LocalContainerPoolSettings from '~/components/settings/LocalContainerPool
31
35
  import SharedStacksPanel from '~/components/settings/SharedStacksPanel.vue'
32
36
  import ComposeEnvironmentSetupSection from '~/components/settings/ComposeEnvironmentSetupSection.vue'
33
37
  import PackageRegistriesPanel from '~/components/settings/PackageRegistriesPanel.vue'
38
+ import CapabilityCredentialsPanel from '~/components/settings/CapabilityCredentialsPanel.vue'
34
39
 
35
40
  const { t } = useI18n()
36
41
  const ui = useUiStore()
37
42
  const store = useProviderConnectionsStore()
38
43
  const auth = useAuthStore()
39
44
  const packageRegistries = usePackageRegistriesStore()
45
+ const capabilityCredentials = useCapabilityCredentialsStore()
46
+ const { canManageSecrets } = useWorkspaceAccess()
40
47
 
41
48
  const open = computed({
42
49
  get: () => ui.infrastructureOpen,
@@ -63,12 +70,14 @@ const TAB_LABELS = computed<Record<InfrastructureTab, string>>(() => ({
63
70
  environment: t('settings.providerConnection.tabs.testEnvironments'),
64
71
  'shared-stacks': t('settings.sharedStacks.tab'),
65
72
  'package-registries': t('settings.packageRegistries.tab'),
73
+ 'capability-credentials': t('settings.capabilityCredentials.tab'),
66
74
  }))
67
75
  const TAB_ICONS: Record<InfrastructureTab, string> = {
68
76
  'runner-pool': 'i-lucide-server-cog',
69
77
  environment: 'i-lucide-cloud',
70
78
  'shared-stacks': 'i-lucide-layers',
71
79
  'package-registries': 'i-lucide-package',
80
+ 'capability-credentials': 'i-lucide-key-round',
72
81
  }
73
82
 
74
83
  // `slot` mirrors `value` — the template names one `<template #…>` per tab value.
@@ -79,6 +88,12 @@ const tabs = computed(() =>
79
88
  // The module's own probe (the backend 503s with no encryption key), same gate as the
80
89
  // Integrations-hub row this replaced — an unconfigured backend shows no dead tab.
81
90
  packageRegistries: packageRegistries.available === true,
91
+ // Two gates, and neither implies the other. `canManageSecrets` hides the tab from a member
92
+ // who may not manage secrets (the view NAMES the deployment's credential keys, which is why
93
+ // the backend gates the read too), and `hasSurface` hides a tab with nothing in it — the
94
+ // panel is a checklist projected from the deployment's registered capabilities, so a build
95
+ // that registers none has no credential to type.
96
+ capabilityCredentials: canManageSecrets.value && capabilityCredentials.hasSurface,
82
97
  }).map((value) => ({
83
98
  value,
84
99
  label: TAB_LABELS.value[value],
@@ -102,6 +117,11 @@ watch(
102
117
  // Swallowed here on purpose: the PANEL reports a load failure, and it can only do that
103
118
  // once the tab it lives in exists, so a probe failure has to leave the window itself alone.
104
119
  void packageRegistries.ensureLoaded().catch(() => {})
120
+ // Same split as the registries probe: swallowed here (a failed probe means no tab, and the
121
+ // window must still open), reported by the panel, which can only do that once its tab exists.
122
+ // Not probed at all without the permission — the backend would refuse it, and asking would
123
+ // put a 403 in every member's console on every open.
124
+ if (canManageSecrets.value) void capabilityCredentials.ensureLoaded().catch(() => {})
105
125
  activeTab.value = openInfrastructureTab(tabValues.value, ui.infrastructureTab)
106
126
  },
107
127
  { immediate: true },
@@ -176,6 +196,9 @@ watch([tabs, () => store.loaded], () => {
176
196
  <template #package-registries>
177
197
  <PackageRegistriesPanel />
178
198
  </template>
199
+ <template #capability-credentials>
200
+ <CapabilityCredentialsPanel />
201
+ </template>
179
202
  </UTabs>
180
203
 
181
204
  <p v-else class="px-1 py-6 text-center text-sm text-slate-500">
@@ -0,0 +1,37 @@
1
+ import {
2
+ deleteCapabilityCredentialContract,
3
+ getCapabilityCredentialsContract,
4
+ setCapabilityCredentialContract,
5
+ } from '@cat-factory/contracts'
6
+ import type { ApiContext } from './context'
7
+
8
+ /**
9
+ * Per-workspace capability credentials (SEALED, write-only). The GET view returns what this
10
+ * deployment's registered capabilities DECLARE joined to what this workspace has stored, never a
11
+ * value. Writes are PER KEY: the whole-set PUT exists for an API caller declaring a whole set at
12
+ * once, and this client could not use it — it never received the other values, so a set-replacing
13
+ * write here would delete every credential the operator did not retype.
14
+ *
15
+ * `secrets.manage`-gated end to end, the READ included: the view carries the credential key names
16
+ * the deployment's capabilities want, which the workspace snapshot deliberately omits.
17
+ * See CapabilityCredentialsController.
18
+ */
19
+ export function capabilityCredentialsApi({ send, ws }: ApiContext) {
20
+ return {
21
+ getCapabilityCredentials: (workspaceId: string) =>
22
+ send(getCapabilityCredentialsContract, { pathPrefix: ws(workspaceId) }),
23
+
24
+ setCapabilityCredential: (workspaceId: string, key: string, value: string) =>
25
+ send(setCapabilityCredentialContract, {
26
+ pathPrefix: ws(workspaceId),
27
+ pathParams: { key },
28
+ body: { value },
29
+ }),
30
+
31
+ deleteCapabilityCredential: (workspaceId: string, key: string) =>
32
+ send(deleteCapabilityCredentialContract, {
33
+ pathPrefix: ws(workspaceId),
34
+ pathParams: { key },
35
+ }),
36
+ }
37
+ }
@@ -31,6 +31,7 @@ import { localSettingsApi } from './api/localSettings'
31
31
  import { modelsApi } from './api/models'
32
32
  import { notificationsApi } from './api/notifications'
33
33
  import { packageRegistriesApi } from './api/packageRegistries'
34
+ import { capabilityCredentialsApi } from './api/capabilityCredentials'
34
35
  import { preflightsApi } from './api/preflights'
35
36
  import { presetsApi } from './api/presets'
36
37
  import { publicApiKeysApi } from './api/publicApiKeys'
@@ -151,6 +152,7 @@ export function useApi() {
151
152
  ...validationChecksApi(ctx),
152
153
  ...testSecretsApi(ctx),
153
154
  ...packageRegistriesApi(ctx),
155
+ ...capabilityCredentialsApi(ctx),
154
156
  ...previewApi(ctx),
155
157
  ...environmentsApi(ctx),
156
158
  ...recurringApi(ctx),
@@ -30,6 +30,11 @@ describe('captureRunDeepLink', () => {
30
30
  })
31
31
  })
32
32
 
33
+ it('parses the captured-evidence link the lifecycle section emits', () => {
34
+ setUrl('?ws=ws_1&block=blk_1&run=exec_1&view=test-evidence')
35
+ expect(captureRunDeepLink()?.view).toBe('test-evidence')
36
+ })
37
+
33
38
  it('strips its own params so a reload does not re-open the panel', () => {
34
39
  setUrl('?ws=ws_1&run=exec_1&view=observability&keep=yes')
35
40
  captureRunDeepLink()
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Boot-time replay of a RUN deep link `?ws=<id>&block=<id>&run=<id>&view=observability`.
2
+ * Boot-time replay of a RUN deep link: `?ws=<id>&block=<id>&run=<id>&view=<panel>`.
3
3
  *
4
- * The engine's PR verification report links each PR back to the run's observability panel
5
- * (Model activity / Provided context), built from the deployment's public app URL. The SPA is a
6
- * single canvas with no URL identity for anything, so this is the narrow consumer that makes
7
- * that link resolve: pin the board before the snapshot loads, then once the board is ready
8
- * select the task and open the panel for the run.
4
+ * The engine's PR verification report links each PR back into the run: to the observability
5
+ * panel (Model activity / Provided context), and to the Tester's result window holding the
6
+ * evidence its environment-lifecycle section lists. Both are built from the deployment's public
7
+ * app URL. The SPA is a single canvas with no URL identity for anything, so this is the narrow
8
+ * consumer that makes those links resolve: pin the board before the snapshot loads, then, once
9
+ * the board is ready, select the task and open the panel for the run.
9
10
  *
10
11
  * Deliberately narrow. The GENERAL parser (every entity, every window, plus state→URL sync) is
11
12
  * slice 4 of `docs/initiatives/global-search-and-deep-links.md`; when it lands, this composable
@@ -81,9 +82,11 @@ export function useRunDeepLink(): void {
81
82
  if (!ready || applied) return
82
83
  applied = true
83
84
  if (link.blockId) ui.select(link.blockId)
84
- // `observability` is the only view this narrow parser serves an unknown view still
85
- // lands the user on the right board and task rather than failing the navigation.
85
+ // Two views are served: the observability panel and the Tester's result window (where the
86
+ // screenshots the report's environment-lifecycle section lists are rendered). An unknown
87
+ // view still lands the user on the right board and task rather than failing the navigation.
86
88
  if (link.view === 'observability') ui.openObservability(link.runId)
89
+ else if (link.view === 'test-evidence') ui.openTestEvidence(link.runId)
87
90
  stop?.()
88
91
  },
89
92
  { immediate: true },