@cat-factory/app 0.221.2 → 0.223.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/layout/NotificationsInbox.vue +5 -0
- package/app/components/panels/ReportsPanel.vue +28 -0
- package/app/components/settings/CapabilityCredentialsPanel.vue +19 -0
- package/app/components/settings/InfrastructureWindow.vue +18 -6
- package/app/components/settings/ToolServerChecklist.vue +264 -0
- package/app/components/slack/SlackPanel.vue +1 -0
- package/app/composables/api/toolServers.ts +21 -0
- package/app/composables/useApi.ts +2 -0
- package/app/stores/toolServers.spec.ts +142 -0
- package/app/stores/toolServers.ts +112 -0
- package/app/types/toolServers.ts +17 -0
- package/i18n/locales/de.json +45 -0
- package/i18n/locales/en.json +45 -0
- package/i18n/locales/es.json +45 -0
- package/i18n/locales/fr.json +45 -0
- package/i18n/locales/he.json +45 -0
- package/i18n/locales/it.json +45 -0
- package/i18n/locales/ja.json +45 -0
- package/i18n/locales/pl.json +45 -0
- package/i18n/locales/tr.json +45 -0
- package/i18n/locales/uk.json +45 -0
- package/package.json +2 -2
|
@@ -84,6 +84,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
|
|
|
84
84
|
// Runs were paused by the spend safeguard. Workspace-scoped (no block to reveal); "act" just
|
|
85
85
|
// marks it read (the human raises the budget then resumes from the spend panel).
|
|
86
86
|
budget_paused: { icon: 'i-lucide-wallet', color: 'warning' },
|
|
87
|
+
// Spend crossed an alert threshold, or is projected to overrun the budget before the period
|
|
88
|
+
// ends. The PROACTIVE sibling of `budget_paused`, so it is amber rather than red: nothing has
|
|
89
|
+
// stopped yet, which is the entire point of it arriving. "act" just marks it read.
|
|
90
|
+
budget_threshold: { icon: 'i-lucide-trending-up', color: 'warning' },
|
|
87
91
|
// Stored credentials could not be decrypted (the ENCRYPTION_KEY changed since they were
|
|
88
92
|
// sealed). Not block-scoped; "act" drops the listed stale ciphertexts so they can be re-entered
|
|
89
93
|
// (or restore the previous key to recover them instead).
|
|
@@ -117,6 +121,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
|
|
|
117
121
|
initiative: 'layout.notifications.action.initiative',
|
|
118
122
|
platform_health: 'layout.notifications.action.platform_health',
|
|
119
123
|
budget_paused: 'layout.notifications.action.budget_paused',
|
|
124
|
+
budget_threshold: 'layout.notifications.action.budget_threshold',
|
|
120
125
|
key_drift: 'layout.notifications.action.key_drift',
|
|
121
126
|
infra_unreachable: 'layout.notifications.action.infra_unreachable',
|
|
122
127
|
}
|
|
@@ -352,6 +352,34 @@ watch(
|
|
|
352
352
|
</section>
|
|
353
353
|
</div>
|
|
354
354
|
|
|
355
|
+
<!-- The TCO axes: what a repository and a ticket actually cost. Spend-only, like the
|
|
356
|
+
pair above, because a run's activity is already sliced by the service that owns
|
|
357
|
+
the repo and there is no second population to pair a ticket with. -->
|
|
358
|
+
<div class="grid gap-6 md:grid-cols-2">
|
|
359
|
+
<section>
|
|
360
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
361
|
+
{{ t('reports.spend.byRepo') }}
|
|
362
|
+
</h2>
|
|
363
|
+
<ReportsSpendBreakdown
|
|
364
|
+
:rows="view.spend.byRepo"
|
|
365
|
+
:currency="currency"
|
|
366
|
+
test-id="reports-spend-repo"
|
|
367
|
+
:label-of="sliceLabel"
|
|
368
|
+
/>
|
|
369
|
+
</section>
|
|
370
|
+
<section>
|
|
371
|
+
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
372
|
+
{{ t('reports.spend.byTicket') }}
|
|
373
|
+
</h2>
|
|
374
|
+
<ReportsSpendBreakdown
|
|
375
|
+
:rows="view.spend.byTicket"
|
|
376
|
+
:currency="currency"
|
|
377
|
+
test-id="reports-spend-ticket"
|
|
378
|
+
:label-of="sliceLabel"
|
|
379
|
+
/>
|
|
380
|
+
</section>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
355
383
|
<!-- The shared axis: spend AND activity for the same grouping, side by side. -->
|
|
356
384
|
<section class="flex flex-col gap-3">
|
|
357
385
|
<div class="flex flex-wrap items-center gap-2">
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
17
17
|
import type { CapabilityCredentialStatus } from '~/types/capabilityCredentials'
|
|
18
18
|
import SecretInput from '~/components/common/SecretInput.vue'
|
|
19
|
+
import ToolServerChecklist from '~/components/settings/ToolServerChecklist.vue'
|
|
19
20
|
|
|
20
21
|
const { t, d } = useI18n()
|
|
21
22
|
const store = useCapabilityCredentialsStore()
|
|
@@ -63,6 +64,19 @@ onMounted(async () => {
|
|
|
63
64
|
}
|
|
64
65
|
})
|
|
65
66
|
|
|
67
|
+
// The tool-server inventory that renders above the checklist. Its own read (and its own failure
|
|
68
|
+
// report, for the same reason): the two surfaces answer different questions off different endpoints,
|
|
69
|
+
// so one failing must not blank the other. Both resolve the same 403, so this is only reached by a
|
|
70
|
+
// caller the backend has already admitted.
|
|
71
|
+
const toolServers = useToolServersStore()
|
|
72
|
+
onMounted(async () => {
|
|
73
|
+
try {
|
|
74
|
+
await toolServers.load()
|
|
75
|
+
} catch (e) {
|
|
76
|
+
present(e, 'settings.toolServers.toast.loadFailed')
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
66
80
|
async function saveKey(key: string) {
|
|
67
81
|
const value = (drafts[key] ?? '').trim()
|
|
68
82
|
if (!value) return
|
|
@@ -99,6 +113,11 @@ async function removeKey(key: string) {
|
|
|
99
113
|
|
|
100
114
|
<template>
|
|
101
115
|
<div class="space-y-4" data-testid="capability-credentials-panel">
|
|
116
|
+
<!-- The servers FIRST, because they are what the keys below authenticate: a bare list of
|
|
117
|
+
variable names does not tell an operator which of them matters, and the Test button is the
|
|
118
|
+
only thing on either surface that can say whether the value they typed works. -->
|
|
119
|
+
<ToolServerChecklist />
|
|
120
|
+
|
|
102
121
|
<p class="text-sm text-slate-400">
|
|
103
122
|
{{ t('settings.capabilityCredentials.intro') }}
|
|
104
123
|
</p>
|
|
@@ -15,10 +15,11 @@
|
|
|
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
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// - "Capability credentials" — the deployment's tool servers (MCP) with a Test button each, above
|
|
19
|
+
// the sealed per-workspace values behind the secrets a registered tool server or generative
|
|
20
|
+
// binary integration declares. What an agent's tools authenticate as belongs beside where those
|
|
21
|
+
// agents run, and it is `secrets.manage`-only (the READ included, since both halves name the
|
|
22
|
+
// deployment's credential keys), so the tab is HIDDEN rather than disabled for anyone else.
|
|
22
23
|
// Local-specific affordances render inline, gated on `auth.localMode?.enabled`. A tab whose
|
|
23
24
|
// backend integration is disabled (503) simply doesn't render.
|
|
24
25
|
import { computed, ref, watch } from 'vue'
|
|
@@ -43,6 +44,7 @@ const store = useProviderConnectionsStore()
|
|
|
43
44
|
const auth = useAuthStore()
|
|
44
45
|
const packageRegistries = usePackageRegistriesStore()
|
|
45
46
|
const capabilityCredentials = useCapabilityCredentialsStore()
|
|
47
|
+
const toolServers = useToolServersStore()
|
|
46
48
|
const { canManageSecrets } = useWorkspaceAccess()
|
|
47
49
|
|
|
48
50
|
const open = computed({
|
|
@@ -93,7 +95,14 @@ const tabs = computed(() =>
|
|
|
93
95
|
// the backend gates the read too), and `hasSurface` hides a tab with nothing in it — the
|
|
94
96
|
// panel is a checklist projected from the deployment's registered capabilities, so a build
|
|
95
97
|
// that registers none has no credential to type.
|
|
96
|
-
|
|
98
|
+
//
|
|
99
|
+
// EITHER surface earns the tab. A tool server that declares no credential has nothing on the
|
|
100
|
+
// checklist, and gating the tab on the checklist alone would leave the one server an operator
|
|
101
|
+
// most wants to test unreachable — while a credential whose capability is a generative
|
|
102
|
+
// integration has no tool-server row. Two questions, one tab, and neither is a subset of the
|
|
103
|
+
// other.
|
|
104
|
+
capabilityCredentials:
|
|
105
|
+
canManageSecrets.value && (capabilityCredentials.hasSurface || toolServers.hasSurface),
|
|
97
106
|
}).map((value) => ({
|
|
98
107
|
value,
|
|
99
108
|
label: TAB_LABELS.value[value],
|
|
@@ -121,7 +130,10 @@ watch(
|
|
|
121
130
|
// window must still open), reported by the panel, which can only do that once its tab exists.
|
|
122
131
|
// Not probed at all without the permission — the backend would refuse it, and asking would
|
|
123
132
|
// put a 403 in every member's console on every open.
|
|
124
|
-
if (canManageSecrets.value)
|
|
133
|
+
if (canManageSecrets.value) {
|
|
134
|
+
void capabilityCredentials.ensureLoaded().catch(() => {})
|
|
135
|
+
void toolServers.ensureLoaded().catch(() => {})
|
|
136
|
+
}
|
|
125
137
|
activeTab.value = openInfrastructureTab(tabValues.value, ui.infrastructureTab)
|
|
126
138
|
},
|
|
127
139
|
{ immediate: true },
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Tool servers (MCP) — the deployment's registered servers, and a Test button that speaks the
|
|
3
|
+
// protocol to one.
|
|
4
|
+
//
|
|
5
|
+
// It sits ABOVE the credential checklist in the same tab, because it is what the credentials on that
|
|
6
|
+
// list are FOR: the checklist answers "which keys does this deployment want", and this answers "and
|
|
7
|
+
// does the server they authenticate actually work". Both are `secrets.manage`-only, which is why the
|
|
8
|
+
// tab is hidden rather than disabled for anyone else.
|
|
9
|
+
//
|
|
10
|
+
// A row states four independent reasons a declared server may never reach a run — no kind declares
|
|
11
|
+
// it, no harness can serve its transport, its credentials do not resolve, the endpoint is dead — and
|
|
12
|
+
// each is rendered rather than implied. Before this panel the first two lived in a boot log and the
|
|
13
|
+
// deployment's own source, and the last two were only visible by starting a run and reading the
|
|
14
|
+
// agent's prompt.
|
|
15
|
+
import { computed, ref } from 'vue'
|
|
16
|
+
import type {
|
|
17
|
+
ToolServerNotProbeableReason,
|
|
18
|
+
ToolServerProbeStatus,
|
|
19
|
+
ToolServerTransport,
|
|
20
|
+
ToolServerView,
|
|
21
|
+
} from '~/types/toolServers'
|
|
22
|
+
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const store = useToolServersStore()
|
|
25
|
+
const { present } = usePipelineErrorToast()
|
|
26
|
+
|
|
27
|
+
// Which failure DETAILS are expanded, by server id. Collapsed by default: the translated status line
|
|
28
|
+
// is what an operator acts on, and the raw backend prose is a disclosure behind it (never the
|
|
29
|
+
// primary description) per the i18n rule.
|
|
30
|
+
const expanded = ref<Record<string, boolean>>({})
|
|
31
|
+
|
|
32
|
+
// Exhaustive Records over the wire vocabularies, so a member added in `@cat-factory/contracts` fails
|
|
33
|
+
// to compile here until it has translated copy. The sanctioned guard for an enum-keyed lookup the
|
|
34
|
+
// typed-message-key check cannot see.
|
|
35
|
+
const TRANSPORT_LABELS = computed<Record<ToolServerTransport, string>>(() => ({
|
|
36
|
+
stdio: t('settings.toolServers.transport.stdio'),
|
|
37
|
+
http: t('settings.toolServers.transport.http'),
|
|
38
|
+
}))
|
|
39
|
+
const STATUS_LABELS = computed<Record<ToolServerProbeStatus, string>>(() => ({
|
|
40
|
+
ok: t('settings.toolServers.status.ok'),
|
|
41
|
+
credentials_missing: t('settings.toolServers.status.credentialsMissing'),
|
|
42
|
+
credential_refused: t('settings.toolServers.status.credentialRefused'),
|
|
43
|
+
unreachable: t('settings.toolServers.status.unreachable'),
|
|
44
|
+
http_error: t('settings.toolServers.status.httpError'),
|
|
45
|
+
protocol_error: t('settings.toolServers.status.protocolError'),
|
|
46
|
+
not_probeable: t('settings.toolServers.status.notProbeable'),
|
|
47
|
+
}))
|
|
48
|
+
const NOT_PROBEABLE_LABELS = computed<Record<ToolServerNotProbeableReason, string>>(() => ({
|
|
49
|
+
stdio_transport: t('settings.toolServers.notProbeable.stdio'),
|
|
50
|
+
container_local_url: t('settings.toolServers.notProbeable.containerLocal'),
|
|
51
|
+
url_not_allowed: t('settings.toolServers.notProbeable.urlNotAllowed'),
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
const servers = computed<ToolServerView[]>(() => store.view?.servers ?? [])
|
|
55
|
+
|
|
56
|
+
function resultFor(id: string) {
|
|
57
|
+
return store.results[id]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Success is the only green; every other verdict names something for the operator to fix. */
|
|
61
|
+
function statusColor(status: ToolServerProbeStatus): 'success' | 'warning' | 'error' {
|
|
62
|
+
if (status === 'ok') return 'success'
|
|
63
|
+
return status === 'not_probeable' ? 'warning' : 'error'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runProbe(id: string) {
|
|
67
|
+
try {
|
|
68
|
+
await store.probe(id)
|
|
69
|
+
} catch (e) {
|
|
70
|
+
// A THROWN failure is not a verdict — a 404 for a server the deployment has since dropped, or a
|
|
71
|
+
// transient 5xx. Presented through the shared status-class funnel rather than stored as a
|
|
72
|
+
// result, so the row does not claim the probe answered.
|
|
73
|
+
present(e, 'settings.toolServers.toast.probeFailed')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<section v-if="servers.length" class="space-y-3" data-testid="tool-servers-section">
|
|
80
|
+
<div>
|
|
81
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
82
|
+
{{ t('settings.toolServers.heading') }}
|
|
83
|
+
</h3>
|
|
84
|
+
<p class="text-xs text-slate-400">{{ t('settings.toolServers.intro') }}</p>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<article
|
|
88
|
+
v-for="server in servers"
|
|
89
|
+
:key="server.id"
|
|
90
|
+
class="space-y-2 rounded-lg border border-slate-700 p-3"
|
|
91
|
+
:data-testid="`tool-server-${server.id}`"
|
|
92
|
+
>
|
|
93
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
94
|
+
<span class="text-sm font-medium text-slate-200">{{ server.label }}</span>
|
|
95
|
+
<UBadge color="neutral" variant="soft" size="sm">
|
|
96
|
+
{{ TRANSPORT_LABELS[server.transport] }}
|
|
97
|
+
</UBadge>
|
|
98
|
+
<code class="font-mono text-[11px] text-slate-500">{{ server.id }}</code>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<p class="truncate font-mono text-[11px] text-slate-500" :title="server.target">
|
|
102
|
+
{{ server.target }}
|
|
103
|
+
</p>
|
|
104
|
+
<p v-if="server.guidance" class="text-xs text-slate-400">{{ server.guidance }}</p>
|
|
105
|
+
|
|
106
|
+
<!-- Which agents get it. An EMPTY list is a registration attached to nothing: it never reaches
|
|
107
|
+
a dispatch, so the credentials it asks for are keys an operator fills in for no run. Said
|
|
108
|
+
out loud, because no other surface in the platform can see that state. -->
|
|
109
|
+
<p v-if="server.declaredBy.length" class="text-[11px] text-slate-400">
|
|
110
|
+
{{ t('settings.toolServers.declaredBy', { kinds: server.declaredBy.join(', ') }) }}
|
|
111
|
+
</p>
|
|
112
|
+
<p v-else class="text-[11px] text-amber-400" :data-testid="`tool-server-orphan-${server.id}`">
|
|
113
|
+
{{ t('settings.toolServers.declaredByNone') }}
|
|
114
|
+
</p>
|
|
115
|
+
|
|
116
|
+
<!-- Which harnesses could serve it. EMPTY means the declaration can never run anywhere (an
|
|
117
|
+
`http` server narrowed to Codex, whose MCP client is stdio-only): it is never dropped FOR
|
|
118
|
+
A REASON on any run, so no prompt and no log line ever mentions it. -->
|
|
119
|
+
<p v-if="server.servableHarnesses.length" class="text-[11px] text-slate-400">
|
|
120
|
+
{{
|
|
121
|
+
t('settings.toolServers.servableHarnesses', {
|
|
122
|
+
harnesses: server.servableHarnesses.join(', '),
|
|
123
|
+
})
|
|
124
|
+
}}
|
|
125
|
+
</p>
|
|
126
|
+
<p v-else class="text-[11px] text-amber-400">
|
|
127
|
+
{{ t('settings.toolServers.servableHarnessesNone') }}
|
|
128
|
+
</p>
|
|
129
|
+
|
|
130
|
+
<p v-if="server.allowedTools?.length" class="text-[11px] text-slate-400">
|
|
131
|
+
{{ t('settings.toolServers.allowedTools', { tools: server.allowedTools.join(', ') }) }}
|
|
132
|
+
</p>
|
|
133
|
+
<p v-if="server.credentials.length" class="text-[11px] text-slate-400">
|
|
134
|
+
{{
|
|
135
|
+
t('settings.toolServers.credentials', {
|
|
136
|
+
keys: server.credentials.map((c) => c.key).join(', '),
|
|
137
|
+
})
|
|
138
|
+
}}
|
|
139
|
+
</p>
|
|
140
|
+
|
|
141
|
+
<div class="flex flex-wrap items-center gap-2 pt-1">
|
|
142
|
+
<UButton
|
|
143
|
+
v-if="server.probeable"
|
|
144
|
+
size="xs"
|
|
145
|
+
variant="subtle"
|
|
146
|
+
icon="i-lucide-plug"
|
|
147
|
+
:loading="store.probing === server.id"
|
|
148
|
+
:disabled="store.probing !== null"
|
|
149
|
+
:data-testid="`tool-server-test-${server.id}`"
|
|
150
|
+
@click="runProbe(server.id)"
|
|
151
|
+
>
|
|
152
|
+
{{ t('settings.toolServers.test') }}
|
|
153
|
+
</UButton>
|
|
154
|
+
<!-- Not a disabled button: nothing the operator can do here makes it clickable, so the row
|
|
155
|
+
states the reason instead. Each reason needs a different response — nothing to fix,
|
|
156
|
+
verify it from a run, or change the declaration. -->
|
|
157
|
+
<p
|
|
158
|
+
v-else-if="server.notProbeableReason"
|
|
159
|
+
class="text-[11px] text-slate-500"
|
|
160
|
+
:data-testid="`tool-server-unprobeable-${server.id}`"
|
|
161
|
+
>
|
|
162
|
+
{{ NOT_PROBEABLE_LABELS[server.notProbeableReason] }}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div
|
|
167
|
+
v-if="resultFor(server.id)"
|
|
168
|
+
class="space-y-1 rounded-md border border-slate-800 bg-slate-900/40 p-2"
|
|
169
|
+
:data-testid="`tool-server-result-${server.id}`"
|
|
170
|
+
>
|
|
171
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
172
|
+
<UBadge
|
|
173
|
+
:color="statusColor(resultFor(server.id)!.status)"
|
|
174
|
+
variant="soft"
|
|
175
|
+
size="sm"
|
|
176
|
+
:data-testid="`tool-server-status-${server.id}`"
|
|
177
|
+
>
|
|
178
|
+
{{ STATUS_LABELS[resultFor(server.id)!.status] }}
|
|
179
|
+
</UBadge>
|
|
180
|
+
<span v-if="resultFor(server.id)!.httpStatus" class="text-[11px] text-slate-400">
|
|
181
|
+
{{ t('settings.toolServers.httpStatus', { status: resultFor(server.id)!.httpStatus }) }}
|
|
182
|
+
</span>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<p v-if="resultFor(server.id)!.status === 'ok'" class="text-[11px] text-slate-300">
|
|
186
|
+
{{
|
|
187
|
+
t('settings.toolServers.okDetail', {
|
|
188
|
+
name: resultFor(server.id)!.serverName || server.id,
|
|
189
|
+
version: resultFor(server.id)!.serverVersion || '?',
|
|
190
|
+
protocol: resultFor(server.id)!.protocolVersion ?? '?',
|
|
191
|
+
count: resultFor(server.id)!.toolCount ?? 0,
|
|
192
|
+
})
|
|
193
|
+
}}
|
|
194
|
+
</p>
|
|
195
|
+
<!-- A count off a truncated read is a FLOOR, not a total, and the difference decides whether
|
|
196
|
+
the allowedTools verdict below means anything. -->
|
|
197
|
+
<p v-if="resultFor(server.id)!.toolsComplete === false" class="text-[11px] text-slate-500">
|
|
198
|
+
{{ t('settings.toolServers.toolsIncomplete') }}
|
|
199
|
+
</p>
|
|
200
|
+
|
|
201
|
+
<!-- The reconciliation nothing else in the platform can do: a well-formed tool name that
|
|
202
|
+
matches nothing narrows the CLI's allow-list to a dead pattern while the prompt keeps
|
|
203
|
+
advertising the tool. Withheld entirely when the tool list was a prefix. -->
|
|
204
|
+
<p
|
|
205
|
+
v-if="resultFor(server.id)!.allowedTools?.unmatched?.length"
|
|
206
|
+
class="text-[11px] text-amber-400"
|
|
207
|
+
:data-testid="`tool-server-unmatched-${server.id}`"
|
|
208
|
+
>
|
|
209
|
+
{{
|
|
210
|
+
t('settings.toolServers.unmatchedTools', {
|
|
211
|
+
tools: resultFor(server.id)!.allowedTools!.unmatched.join(', '),
|
|
212
|
+
})
|
|
213
|
+
}}
|
|
214
|
+
</p>
|
|
215
|
+
<p
|
|
216
|
+
v-else-if="resultFor(server.id)!.allowedTools?.checked === false"
|
|
217
|
+
class="text-[11px] text-slate-500"
|
|
218
|
+
>
|
|
219
|
+
{{ t('settings.toolServers.allowedToolsUnchecked') }}
|
|
220
|
+
</p>
|
|
221
|
+
|
|
222
|
+
<p
|
|
223
|
+
v-if="resultFor(server.id)!.unresolvedCredentials?.length"
|
|
224
|
+
class="text-[11px] text-amber-400"
|
|
225
|
+
>
|
|
226
|
+
{{
|
|
227
|
+
t('settings.toolServers.unresolvedCredentials', {
|
|
228
|
+
keys: resultFor(server.id)!.unresolvedCredentials!.join(', '),
|
|
229
|
+
})
|
|
230
|
+
}}
|
|
231
|
+
</p>
|
|
232
|
+
<p v-if="resultFor(server.id)!.refusedCredentials?.length" class="text-[11px] text-red-400">
|
|
233
|
+
{{
|
|
234
|
+
t('settings.toolServers.refusedCredentials', {
|
|
235
|
+
keys: resultFor(server.id)!.refusedCredentials!.join(', '),
|
|
236
|
+
})
|
|
237
|
+
}}
|
|
238
|
+
</p>
|
|
239
|
+
|
|
240
|
+
<!-- Raw backend prose is DETAIL behind a disclosure, never the primary description. It is
|
|
241
|
+
already scrubbed through `redactSecrets` at the emit site. -->
|
|
242
|
+
<template v-if="resultFor(server.id)!.error">
|
|
243
|
+
<UButton
|
|
244
|
+
size="xs"
|
|
245
|
+
variant="link"
|
|
246
|
+
class="px-0"
|
|
247
|
+
:data-testid="`tool-server-details-${server.id}`"
|
|
248
|
+
@click="expanded[server.id] = !expanded[server.id]"
|
|
249
|
+
>
|
|
250
|
+
{{
|
|
251
|
+
expanded[server.id]
|
|
252
|
+
? t('settings.toolServers.hideDetails')
|
|
253
|
+
: t('settings.toolServers.showDetails')
|
|
254
|
+
}}
|
|
255
|
+
</UButton>
|
|
256
|
+
<pre
|
|
257
|
+
v-if="expanded[server.id]"
|
|
258
|
+
class="overflow-x-auto rounded bg-slate-950 p-2 font-mono text-[10px] text-slate-400"
|
|
259
|
+
>{{ resultFor(server.id)!.error }}</pre>
|
|
260
|
+
</template>
|
|
261
|
+
</div>
|
|
262
|
+
</article>
|
|
263
|
+
</section>
|
|
264
|
+
</template>
|
|
@@ -74,6 +74,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
|
|
|
74
74
|
initiative: { enabled: false, channel: '' },
|
|
75
75
|
platform_health: { enabled: false, channel: '' },
|
|
76
76
|
budget_paused: { enabled: false, channel: '' },
|
|
77
|
+
budget_threshold: { enabled: false, channel: '' },
|
|
77
78
|
// In-app only (not in ROUTABLE), but the map is exhaustive over the type.
|
|
78
79
|
key_drift: { enabled: false, channel: '' },
|
|
79
80
|
infra_unreachable: { enabled: false, channel: '' },
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listToolServersContract, probeToolServerContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-workspace tool-server (MCP) operability: the inventory of what this deployment declared, and
|
|
6
|
+
* a probe that speaks the protocol to one of them.
|
|
7
|
+
*
|
|
8
|
+
* `secrets.manage`-gated end to end, the READ included — the inventory names the credential keys the
|
|
9
|
+
* deployment's capabilities want and the endpoints those credentials are sent to. The probe is a
|
|
10
|
+
* POST because it SPENDS an outbound request under the deployment's own credential, so it must not
|
|
11
|
+
* be safe to retry from a cache or a prefetch. See ToolServerController.
|
|
12
|
+
*/
|
|
13
|
+
export function toolServersApi({ send, ws }: ApiContext) {
|
|
14
|
+
return {
|
|
15
|
+
listToolServers: (workspaceId: string) =>
|
|
16
|
+
send(listToolServersContract, { pathPrefix: ws(workspaceId) }),
|
|
17
|
+
|
|
18
|
+
probeToolServer: (workspaceId: string, id: string) =>
|
|
19
|
+
send(probeToolServerContract, { pathPrefix: ws(workspaceId), pathParams: { id } }),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -33,6 +33,7 @@ import { modelsApi } from './api/models'
|
|
|
33
33
|
import { notificationsApi } from './api/notifications'
|
|
34
34
|
import { packageRegistriesApi } from './api/packageRegistries'
|
|
35
35
|
import { capabilityCredentialsApi } from './api/capabilityCredentials'
|
|
36
|
+
import { toolServersApi } from './api/toolServers'
|
|
36
37
|
import { preflightsApi } from './api/preflights'
|
|
37
38
|
import { presetsApi } from './api/presets'
|
|
38
39
|
import { publicApiKeysApi } from './api/publicApiKeys'
|
|
@@ -156,6 +157,7 @@ export function useApi() {
|
|
|
156
157
|
...testSecretsApi(ctx),
|
|
157
158
|
...packageRegistriesApi(ctx),
|
|
158
159
|
...capabilityCredentialsApi(ctx),
|
|
160
|
+
...toolServersApi(ctx),
|
|
159
161
|
...previewApi(ctx),
|
|
160
162
|
...environmentsApi(ctx),
|
|
161
163
|
...recurringApi(ctx),
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useToolServersStore } from '~/stores/toolServers'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { ToolServerProbeResult, ToolServerView } from '~/types/toolServers'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Three behaviours carry this store, each about not losing an answer:
|
|
8
|
+
*
|
|
9
|
+
* - the availability probe. A 403 ("you may not manage secrets") is an ANSWER and resolves
|
|
10
|
+
* normally, hiding the surface; anything else propagates, because the panel is what can tell a
|
|
11
|
+
* reader the list could not be fetched. There is deliberately no 503 case, unlike the
|
|
12
|
+
* credential store: projecting a registry needs no encryption key.
|
|
13
|
+
* - a probe result is kept for every VERDICT, failures included. A failure is exactly the answer
|
|
14
|
+
* the operator asked for, and a store that only kept successes would leave the row looking
|
|
15
|
+
* untouched after a dead endpoint was reported.
|
|
16
|
+
* - results survive a re-read of the inventory, because a result describes the SERVER rather than
|
|
17
|
+
* the list it arrived in.
|
|
18
|
+
*/
|
|
19
|
+
function server(over: Partial<ToolServerView> = {}): ToolServerView {
|
|
20
|
+
return {
|
|
21
|
+
id: 'issues',
|
|
22
|
+
label: 'Issue tracker',
|
|
23
|
+
transport: 'http',
|
|
24
|
+
target: 'https://mcp.example/rpc',
|
|
25
|
+
declaredBy: ['coder'],
|
|
26
|
+
servableHarnesses: ['claude-code'],
|
|
27
|
+
credentials: [],
|
|
28
|
+
probeable: true,
|
|
29
|
+
...over,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('toolServers store', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('load stores the inventory and marks the surface available', async () => {
|
|
39
|
+
vi.stubGlobal('useApi', () => ({
|
|
40
|
+
listToolServers: () => Promise.resolve({ servers: [server()] }),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
const store = useToolServersStore()
|
|
44
|
+
await store.load()
|
|
45
|
+
|
|
46
|
+
expect(store.available).toBe(true)
|
|
47
|
+
expect(store.hasSurface).toBe(true)
|
|
48
|
+
expect(store.loading).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shares one read between concurrent callers', async () => {
|
|
52
|
+
let reads = 0
|
|
53
|
+
vi.stubGlobal('useApi', () => ({
|
|
54
|
+
listToolServers: () => {
|
|
55
|
+
reads++
|
|
56
|
+
return Promise.resolve({ servers: [server()] })
|
|
57
|
+
},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
const store = useToolServersStore()
|
|
61
|
+
// Both callers fire on the same interaction: the Infrastructure window asks `ensureLoaded` whether
|
|
62
|
+
// the tab exists at all, and the panel refreshes on mount so a redeploy shows up without a reload.
|
|
63
|
+
// A read that started microseconds ago IS that refresh, so it is shared rather than duplicated.
|
|
64
|
+
await Promise.all([store.ensureLoaded(), store.load()])
|
|
65
|
+
|
|
66
|
+
expect(reads).toBe(1)
|
|
67
|
+
expect(store.hasSurface).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('a 403 latches the surface unavailable without throwing', async () => {
|
|
71
|
+
vi.stubGlobal('useApi', () => ({
|
|
72
|
+
listToolServers: () => Promise.reject({ statusCode: 403 }),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const store = useToolServersStore()
|
|
76
|
+
await expect(store.load()).resolves.toBeUndefined()
|
|
77
|
+
|
|
78
|
+
expect(store.available).toBe(false)
|
|
79
|
+
expect(store.hasSurface).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a transient failure propagates and latches nothing', async () => {
|
|
83
|
+
vi.stubGlobal('useApi', () => ({
|
|
84
|
+
listToolServers: () => Promise.reject({ statusCode: 500 }),
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
const store = useToolServersStore()
|
|
88
|
+
await expect(store.load()).rejects.toBeDefined()
|
|
89
|
+
|
|
90
|
+
// `available` stays null so `ensureLoaded` remains retryable, and an already-loaded surface is
|
|
91
|
+
// not hidden by one bad read.
|
|
92
|
+
expect(store.available).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('reports no surface for a deployment that registers no tool server', async () => {
|
|
96
|
+
vi.stubGlobal('useApi', () => ({ listToolServers: () => Promise.resolve({ servers: [] }) }))
|
|
97
|
+
|
|
98
|
+
const store = useToolServersStore()
|
|
99
|
+
await store.load()
|
|
100
|
+
|
|
101
|
+
// An answer rather than possibly an outage: the inventory is read off this process's own
|
|
102
|
+
// registry, which is why there is no `declarationsIncomplete` counterpart here.
|
|
103
|
+
expect(store.available).toBe(true)
|
|
104
|
+
expect(store.hasSurface).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps a FAILING probe result, and keeps it across a re-read of the inventory', async () => {
|
|
108
|
+
const failure: ToolServerProbeResult = {
|
|
109
|
+
serverId: 'issues',
|
|
110
|
+
status: 'unreachable',
|
|
111
|
+
error: 'TypeError: fetch failed',
|
|
112
|
+
}
|
|
113
|
+
vi.stubGlobal('useApi', () => ({
|
|
114
|
+
listToolServers: () => Promise.resolve({ servers: [server()] }),
|
|
115
|
+
probeToolServer: () => Promise.resolve(failure),
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const store = useToolServersStore()
|
|
119
|
+
await store.load()
|
|
120
|
+
await store.probe('issues')
|
|
121
|
+
|
|
122
|
+
expect(store.results.issues).toEqual(failure)
|
|
123
|
+
expect(store.probing).toBeNull()
|
|
124
|
+
|
|
125
|
+
await store.load()
|
|
126
|
+
expect(store.results.issues).toEqual(failure)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('propagates a THROWN probe failure without storing a verdict', async () => {
|
|
130
|
+
// A 404 for a server the deployment has since dropped, or a transient 5xx: not a verdict, so the
|
|
131
|
+
// row must not claim the probe answered.
|
|
132
|
+
vi.stubGlobal('useApi', () => ({
|
|
133
|
+
probeToolServer: () => Promise.reject({ statusCode: 404 }),
|
|
134
|
+
}))
|
|
135
|
+
|
|
136
|
+
const store = useToolServersStore()
|
|
137
|
+
await expect(store.probe('ghost')).rejects.toBeDefined()
|
|
138
|
+
|
|
139
|
+
expect(store.results.ghost).toBeUndefined()
|
|
140
|
+
expect(store.probing).toBeNull()
|
|
141
|
+
})
|
|
142
|
+
})
|