@cat-factory/app 0.110.0 → 0.110.2
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.
|
@@ -30,6 +30,7 @@ const back = useIntegrationBack(open)
|
|
|
30
30
|
// refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
|
|
31
31
|
// ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
|
|
32
32
|
const RECOMMENDED_SLUGS = [
|
|
33
|
+
'anthropic/claude-fable-5',
|
|
33
34
|
'anthropic/claude-opus-4.8',
|
|
34
35
|
'openai/gpt-5.5',
|
|
35
36
|
'google/gemini-3-pro',
|
package/app/stores/execution.ts
CHANGED
|
@@ -219,35 +219,29 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
// Interacting with a running individual-usage run (resolve/approve/request-changes)
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
222
|
+
// Interacting with a running individual-usage run (resolve/approve/request-changes) advances
|
|
223
|
+
// + re-dispatches the run, so the server re-mints its short-TTL activation from the personal
|
|
224
|
+
// password first. It rides the cached password transparently, and — like start/retry — is
|
|
225
|
+
// gated through `withCredential`: a within-buffer/lapsed cache re-prompts EARLY here (while
|
|
226
|
+
// the user is present) rather than letting the run break mid-pipeline. For a non-individual
|
|
227
|
+
// run the server ignores it and nothing prompts.
|
|
226
228
|
async function resolveDecision(instanceId: string, decisionId: string, choice: string) {
|
|
227
229
|
const ws = useWorkspaceStore()
|
|
228
230
|
const personal = usePersonalSubscriptionsStore()
|
|
229
|
-
await
|
|
230
|
-
ws.requireId(),
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
{ choice },
|
|
234
|
-
personal.getCachedPassword(),
|
|
235
|
-
)
|
|
236
|
-
await ws.refresh()
|
|
231
|
+
return await personal.withCredential(async (password) => {
|
|
232
|
+
await api.resolveDecision(ws.requireId(), instanceId, decisionId, { choice }, password)
|
|
233
|
+
await ws.refresh()
|
|
234
|
+
})
|
|
237
235
|
}
|
|
238
236
|
|
|
239
237
|
/** Approve a step's gated proposal (optionally edited); the run advances. */
|
|
240
238
|
async function approveStep(instanceId: string, approvalId: string, proposal?: string) {
|
|
241
239
|
const ws = useWorkspaceStore()
|
|
242
240
|
const personal = usePersonalSubscriptionsStore()
|
|
243
|
-
await
|
|
244
|
-
ws.requireId(),
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
{ proposal },
|
|
248
|
-
personal.getCachedPassword(),
|
|
249
|
-
)
|
|
250
|
-
await ws.refresh()
|
|
241
|
+
return await personal.withCredential(async (password) => {
|
|
242
|
+
await api.approveStep(ws.requireId(), instanceId, approvalId, { proposal }, password)
|
|
243
|
+
await ws.refresh()
|
|
244
|
+
})
|
|
251
245
|
}
|
|
252
246
|
|
|
253
247
|
/** Request changes on a gated proposal; the step re-runs with the review. */
|
|
@@ -258,14 +252,10 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
258
252
|
) {
|
|
259
253
|
const ws = useWorkspaceStore()
|
|
260
254
|
const personal = usePersonalSubscriptionsStore()
|
|
261
|
-
await
|
|
262
|
-
ws.requireId(),
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
review,
|
|
266
|
-
personal.getCachedPassword(),
|
|
267
|
-
)
|
|
268
|
-
await ws.refresh()
|
|
255
|
+
return await personal.withCredential(async (password) => {
|
|
256
|
+
await api.requestStepChanges(ws.requireId(), instanceId, approvalId, review, password)
|
|
257
|
+
await ws.refresh()
|
|
258
|
+
})
|
|
269
259
|
}
|
|
270
260
|
|
|
271
261
|
/** Reject a gated proposal; the run stops entirely (a retryable failure). */
|
|
@@ -278,8 +268,9 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
278
268
|
/**
|
|
279
269
|
* Resolve a companion step parked at its rework cap: extra-round (one more pass) /
|
|
280
270
|
* proceed (advance with the current output) / stop-reset (cancel + reset the task).
|
|
281
|
-
* Rides the cached personal password
|
|
282
|
-
*
|
|
271
|
+
* Rides the cached personal password (gated through `withCredential`, so a within-buffer
|
|
272
|
+
* cache re-prompts early) for the server to re-mint the run's activation before
|
|
273
|
+
* re-dispatching on extra-round/proceed.
|
|
283
274
|
*/
|
|
284
275
|
async function resolveCompanionExceeded(
|
|
285
276
|
instanceId: string,
|
|
@@ -288,14 +279,16 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
288
279
|
) {
|
|
289
280
|
const ws = useWorkspaceStore()
|
|
290
281
|
const personal = usePersonalSubscriptionsStore()
|
|
291
|
-
await
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
282
|
+
return await personal.withCredential(async (password) => {
|
|
283
|
+
await api.resolveCompanionExceeded(
|
|
284
|
+
ws.requireId(),
|
|
285
|
+
instanceId,
|
|
286
|
+
approvalId,
|
|
287
|
+
{ choice },
|
|
288
|
+
password,
|
|
289
|
+
)
|
|
290
|
+
await ws.refresh()
|
|
291
|
+
})
|
|
299
292
|
}
|
|
300
293
|
|
|
301
294
|
/** How many approval gates anywhere are awaiting a human. */
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { ApiError } from '~/composables/api/errors'
|
|
3
|
+
import { usePersonalSubscriptionsStore } from '~/stores/personalSubscriptions'
|
|
4
|
+
|
|
5
|
+
// The single localStorage key the store caches the personal password under (private to the
|
|
6
|
+
// store; hard-coded here so the buffer/expiry semantics can be asserted directly).
|
|
7
|
+
const CACHE_KEY = 'cf.personal-pw'
|
|
8
|
+
const HOUR = 60 * 60 * 1000
|
|
9
|
+
/** Mirrors PASSWORD_EXPIRY_BUFFER_MS in the store — the runway a key must have to be ridden. */
|
|
10
|
+
const BUFFER_MS = 8 * HOUR
|
|
11
|
+
|
|
12
|
+
/** Write a cache entry with an explicit remaining lifetime (positive = valid, negative = past). */
|
|
13
|
+
function seedCache(password: string, msFromNow: number) {
|
|
14
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify({ password, expiresAt: Date.now() + msFromNow }))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A 428 credential_required error shaped like the server envelope the store parses. */
|
|
18
|
+
function credentialError() {
|
|
19
|
+
return new ApiError(428, {
|
|
20
|
+
error: {
|
|
21
|
+
code: 'credential_required',
|
|
22
|
+
message: 'Enter your personal password.',
|
|
23
|
+
details: { vendor: 'claude', reason: 'password_required' },
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Let queued microtasks (the first withCredential attempt + its catch) settle. */
|
|
29
|
+
async function flush() {
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('personal-password cache expiry buffer', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
localStorage.clear()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns a healthy key both plainly and within the buffer', () => {
|
|
39
|
+
const store = usePersonalSubscriptionsStore()
|
|
40
|
+
seedCache('secret', 40 * HOUR)
|
|
41
|
+
expect(store.getCachedPassword()).toBe('secret')
|
|
42
|
+
expect(store.getCachedPassword(BUFFER_MS)).toBe('secret')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('withholds a within-buffer key for a buffered read but KEEPS it in storage', () => {
|
|
46
|
+
const store = usePersonalSubscriptionsStore()
|
|
47
|
+
seedCache('secret', 2 * HOUR) // still valid, but under the 8h buffer
|
|
48
|
+
// Plain read still returns it (it has not truly expired)…
|
|
49
|
+
expect(store.getCachedPassword()).toBe('secret')
|
|
50
|
+
// …but a buffered read withholds it so the action re-prompts early.
|
|
51
|
+
expect(store.getCachedPassword(BUFFER_MS)).toBeUndefined()
|
|
52
|
+
// Crucially it is NOT dropped — the key is still valid, just deliberately refreshed early.
|
|
53
|
+
expect(localStorage.getItem(CACHE_KEY)).not.toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('removes a truly-expired key on read', () => {
|
|
57
|
+
const store = usePersonalSubscriptionsStore()
|
|
58
|
+
seedCache('stale', -HOUR)
|
|
59
|
+
expect(store.getCachedPassword()).toBeUndefined()
|
|
60
|
+
expect(localStorage.getItem(CACHE_KEY)).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('withCredential early re-entry', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
localStorage.clear()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('rides a healthy cached key without prompting', async () => {
|
|
70
|
+
const store = usePersonalSubscriptionsStore()
|
|
71
|
+
seedCache('secret', 40 * HOUR)
|
|
72
|
+
// The action only fails when it receives no password; a healthy key is passed through.
|
|
73
|
+
const action = vi.fn(async (pw?: string) => {
|
|
74
|
+
if (!pw) throw credentialError()
|
|
75
|
+
})
|
|
76
|
+
const ran = await store.withCredential(action)
|
|
77
|
+
expect(ran).toBe(true)
|
|
78
|
+
expect(action).toHaveBeenCalledWith('secret')
|
|
79
|
+
expect(store.pending).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('prompts EARLY when the cached key is within the buffer, then refreshes it on re-entry', async () => {
|
|
83
|
+
const store = usePersonalSubscriptionsStore()
|
|
84
|
+
seedCache('secret', 2 * HOUR) // valid but within the 8h buffer → withheld on the first try
|
|
85
|
+
const action = vi.fn(async (pw?: string) => {
|
|
86
|
+
if (!pw) throw credentialError()
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
const done = store.withCredential(action)
|
|
90
|
+
await flush()
|
|
91
|
+
|
|
92
|
+
// The buffered first attempt sent no password → the server 428'd → the modal opened,
|
|
93
|
+
// even though the key had NOT actually expired yet. That is the early re-entry.
|
|
94
|
+
expect(action).toHaveBeenCalledWith(undefined)
|
|
95
|
+
expect(store.pending).not.toBeNull()
|
|
96
|
+
expect(store.pending?.reason).toBe('password_required')
|
|
97
|
+
|
|
98
|
+
const pending = store.pending!
|
|
99
|
+
await pending.retry('fresh-secret')
|
|
100
|
+
|
|
101
|
+
await expect(done).resolves.toBe(true)
|
|
102
|
+
expect(action).toHaveBeenLastCalledWith('fresh-secret')
|
|
103
|
+
expect(store.pending).toBeNull()
|
|
104
|
+
// The re-entered password is cached with a full (well beyond the buffer) window.
|
|
105
|
+
const cached = JSON.parse(localStorage.getItem(CACHE_KEY)!) as {
|
|
106
|
+
password: string
|
|
107
|
+
expiresAt: number
|
|
108
|
+
}
|
|
109
|
+
expect(cached.password).toBe('fresh-secret')
|
|
110
|
+
expect(cached.expiresAt - Date.now()).toBeGreaterThan(BUFFER_MS)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -19,6 +19,13 @@ import type {
|
|
|
19
19
|
// sent as the `X-Personal-Password` header — and the user is only re-prompted once it
|
|
20
20
|
// expires (or is wrong).
|
|
21
21
|
//
|
|
22
|
+
// Every gated action (start / retry / confirm) re-validates the cache against an 8h EXPIRY
|
|
23
|
+
// BUFFER: a key with less than that runway left is withheld (treated as absent) so the
|
|
24
|
+
// server's 428 gate re-challenges EARLY and the modal refreshes the full window. This is
|
|
25
|
+
// what keeps a key from lapsing MID-PIPELINE — the run breaks with a retry only if the key
|
|
26
|
+
// was allowed to run down while the user wasn't looking, so we ask for re-entry while they
|
|
27
|
+
// still are (at the start/confirm/retry they just triggered), not later.
|
|
28
|
+
//
|
|
22
29
|
// Caching here is a DELIBERATE convenience choice, not a security weakness. The password
|
|
23
30
|
// layer exists to prevent ACCIDENTAL misuse (a credential can't be silently pooled); the
|
|
24
31
|
// real at-rest protection is the server's system encryption, which the cache doesn't touch.
|
|
@@ -30,6 +37,12 @@ import type {
|
|
|
30
37
|
|
|
31
38
|
/** How long a typed password stays cached before the user is re-prompted (40h). */
|
|
32
39
|
const PASSWORD_TTL_MS = 40 * 60 * 60 * 1000
|
|
40
|
+
/**
|
|
41
|
+
* Runway a cached password must still have for a gated action to ride it. Within this
|
|
42
|
+
* window of expiry the key is withheld so the action re-challenges and refreshes the cache
|
|
43
|
+
* EARLY — a buffer wide enough that a pipeline kicked off now won't outlive the key (8h).
|
|
44
|
+
*/
|
|
45
|
+
const PASSWORD_EXPIRY_BUFFER_MS = 8 * 60 * 60 * 1000
|
|
33
46
|
const CACHE_KEY = 'cf.personal-pw'
|
|
34
47
|
|
|
35
48
|
/** A credential prompt the UI must satisfy (set when the server replies 428). */
|
|
@@ -98,7 +111,13 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
98
111
|
const renewals = computed(() => subscriptions.value.filter((s) => s.renewSoon))
|
|
99
112
|
|
|
100
113
|
// --- client-side password cache (single localStorage key + TTL) ------------
|
|
101
|
-
|
|
114
|
+
/**
|
|
115
|
+
* The cached password, or `undefined` when there is none to ride. `bufferMs` withholds a
|
|
116
|
+
* still-valid key that is within that window of expiry (WITHOUT dropping it) so a gated
|
|
117
|
+
* action re-challenges early; a truly-expired key is always removed. Default `0` = the
|
|
118
|
+
* plain "is it still valid right now" read.
|
|
119
|
+
*/
|
|
120
|
+
function getCachedPassword(bufferMs = 0): string | undefined {
|
|
102
121
|
if (typeof localStorage === 'undefined') return undefined
|
|
103
122
|
try {
|
|
104
123
|
const raw = localStorage.getItem(CACHE_KEY)
|
|
@@ -108,6 +127,9 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
108
127
|
localStorage.removeItem(CACHE_KEY)
|
|
109
128
|
return undefined
|
|
110
129
|
}
|
|
130
|
+
// Within the buffer the key is still valid — keep it, but withhold it so the action
|
|
131
|
+
// re-challenges and refreshes the full window before it can lapse mid-pipeline.
|
|
132
|
+
if (Date.now() + bufferMs > expiresAt) return undefined
|
|
111
133
|
return password
|
|
112
134
|
} catch {
|
|
113
135
|
return undefined
|
|
@@ -147,9 +169,11 @@ export const usePersonalSubscriptionsStore = defineStore('personalSubscriptions'
|
|
|
147
169
|
*/
|
|
148
170
|
async function withCredential(action: (password?: string) => Promise<void>): Promise<boolean> {
|
|
149
171
|
// First attempt may carry no password (non-individual runs) or the single cached one;
|
|
150
|
-
// the server only consults it when the block needs it.
|
|
172
|
+
// the server only consults it when the block needs it. A key within the expiry buffer
|
|
173
|
+
// is withheld so an individual-usage action 428s and re-prompts EARLY (refreshing the
|
|
174
|
+
// window) rather than riding a key that could lapse mid-pipeline.
|
|
151
175
|
try {
|
|
152
|
-
await action(getCachedPassword())
|
|
176
|
+
await action(getCachedPassword(PASSWORD_EXPIRY_BUFFER_MS))
|
|
153
177
|
return true
|
|
154
178
|
} catch (error) {
|
|
155
179
|
const credential = parseCredentialError(error)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.110.
|
|
3
|
+
"version": "0.110.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|