@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.
- package/app/components/settings/CapabilityCredentialsPanel.vue +272 -0
- package/app/components/settings/InfrastructureWindow.logic.spec.ts +28 -3
- package/app/components/settings/InfrastructureWindow.logic.ts +15 -1
- package/app/components/settings/InfrastructureWindow.vue +23 -0
- package/app/composables/api/capabilityCredentials.ts +37 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useRunDeepLink.spec.ts +5 -0
- package/app/composables/useRunDeepLink.ts +11 -8
- package/app/stores/capabilityCredentials.spec.ts +163 -0
- package/app/stores/capabilityCredentials.ts +102 -0
- package/app/stores/ui/resultViews.ts +12 -78
- package/app/stores/ui/runStepOpeners.ts +143 -0
- package/app/stores/ui.dispatch.spec.ts +38 -0
- package/app/types/capabilityCredentials.ts +12 -0
- package/app/types/providerConnections.ts +5 -1
- package/i18n/locales/de.json +35 -0
- package/i18n/locales/en.json +35 -0
- package/i18n/locales/es.json +35 -0
- package/i18n/locales/fr.json +35 -0
- package/i18n/locales/he.json +35 -0
- package/i18n/locales/it.json +35 -0
- package/i18n/locales/ja.json +35 -0
- package/i18n/locales/pl.json +35 -0
- package/i18n/locales/tr.json +35 -0
- package/i18n/locales/uk.json +35 -0
- package/package.json +2 -2
|
@@ -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 = {
|
|
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({
|
|
34
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
5
|
-
* (Model activity / Provided context),
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
//
|
|
85
|
-
//
|
|
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 },
|