@aexol/spectral 0.9.245 → 0.9.247
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/CHANGELOG.md +18 -0
- package/dist/cli.js +6 -0
- package/dist/commands/secret.d.ts +16 -0
- package/dist/commands/secret.d.ts.map +1 -0
- package/dist/commands/secret.js +153 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +21 -0
- package/dist/extensions/list-secrets.d.ts +14 -0
- package/dist/extensions/list-secrets.d.ts.map +1 -0
- package/dist/extensions/list-secrets.js +63 -0
- package/dist/generated/zeus/const.d.ts.map +1 -1
- package/dist/generated/zeus/const.js +16 -0
- package/dist/generated/zeus/index.d.ts +73 -0
- package/dist/generated/zeus/index.d.ts.map +1 -1
- package/dist/relay/routing.d.ts +1 -1
- package/dist/relay/routing.d.ts.map +1 -1
- package/dist/relay/routing.js +93 -17
- package/dist/sdk/coding-agent/utils/shell.d.ts.map +1 -1
- package/dist/sdk/coding-agent/utils/shell.js +15 -1
- package/dist/secrets/index.d.ts +11 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +10 -0
- package/dist/secrets/store.d.ts +109 -0
- package/dist/secrets/store.d.ts.map +1 -0
- package/dist/secrets/store.js +415 -0
- package/dist/server/agent-bridge.d.ts.map +1 -1
- package/dist/server/agent-bridge.js +20 -2
- package/dist/server/attachments/cleanup.d.ts +42 -0
- package/dist/server/attachments/cleanup.d.ts.map +1 -0
- package/dist/server/attachments/cleanup.js +100 -0
- package/dist/server/attachments/persist.d.ts +131 -0
- package/dist/server/attachments/persist.d.ts.map +1 -0
- package/dist/server/attachments/persist.js +406 -0
- package/dist/server/handlers/secrets.d.ts +40 -0
- package/dist/server/handlers/secrets.d.ts.map +1 -0
- package/dist/server/handlers/secrets.js +78 -0
- package/dist/server/session-stream.d.ts +5 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +51 -1
- package/dist/server/storage.d.ts +6 -0
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local encrypted secret store.
|
|
3
|
+
*
|
|
4
|
+
* Storage layout (both files under `getConfigDir()`, honoring
|
|
5
|
+
* `SPECTRAL_CONFIG_DIR` for tests):
|
|
6
|
+
* - `<configDir>/secrets.key` 32 random bytes, mode 0600 — AES-256 key
|
|
7
|
+
* - `<configDir>/secrets.json.enc` AES-256-GCM encrypted JSON, mode 0600
|
|
8
|
+
*
|
|
9
|
+
* Encrypted file layout:
|
|
10
|
+
* - bytes 0-7: magic `"SPSECRT1"` (format tag)
|
|
11
|
+
* - bytes 8-19: 12-byte random IV
|
|
12
|
+
* - bytes 20-35: 16-byte GCM auth tag
|
|
13
|
+
* - bytes 36+: ciphertext (UTF-8 JSON)
|
|
14
|
+
*
|
|
15
|
+
* Security rules enforced here and by this module's consumers:
|
|
16
|
+
* - `listSecrets()` returns ONLY `{ name, createdAt, updatedAt }` — values are
|
|
17
|
+
* never part of any listing.
|
|
18
|
+
* - `getSecretValue()` is INTERNAL: it is consumed only by
|
|
19
|
+
* `getSecretEnvVars()` (env-var injection into the agent's bash tool). It
|
|
20
|
+
* is deliberately NOT re-exported from `./index.js`, so neither the relay
|
|
21
|
+
* handlers nor the LLM-facing tool surface can reach it.
|
|
22
|
+
* - No caller ever logs a value; error messages and listings carry names only.
|
|
23
|
+
*
|
|
24
|
+
* All I/O is synchronous by design: `getSecretEnvVars()` must be callable from
|
|
25
|
+
* the sync `getShellEnv()` in `src/sdk/coding-agent/utils/shell.ts`. The store
|
|
26
|
+
* is tiny (values are capped at 64 KiB each), so sync reads are negligible.
|
|
27
|
+
*
|
|
28
|
+
* Keep this module zero-side-effect on import: nothing touches the filesystem
|
|
29
|
+
* until one of the exported functions is called.
|
|
30
|
+
*/
|
|
31
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
32
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
33
|
+
import { basename, dirname, join } from "node:path";
|
|
34
|
+
import { getConfigDir } from "../config.js";
|
|
35
|
+
/** Secret names: start with a letter, then letters/digits/underscores, max 64 chars. */
|
|
36
|
+
export const SECRET_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
37
|
+
/** Maximum UTF-8 byte size of a secret value (64 KiB). */
|
|
38
|
+
export const MAX_SECRET_VALUE_BYTES = 64 * 1024;
|
|
39
|
+
/** 8-byte format tag at the head of the encrypted store. */
|
|
40
|
+
const MAGIC = Buffer.from("SPSECRT1", "utf8");
|
|
41
|
+
const IV_LENGTH = 12;
|
|
42
|
+
const TAG_LENGTH = 16;
|
|
43
|
+
const KEY_LENGTH = 32;
|
|
44
|
+
/** Thrown when a secret name is missing/invalid after normalization. */
|
|
45
|
+
export class SecretNameError extends Error {
|
|
46
|
+
constructor(message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "SecretNameError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Thrown when a secret value exceeds `MAX_SECRET_VALUE_BYTES`. */
|
|
52
|
+
export class SecretValueError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "SecretValueError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Thrown when the encrypted store is unreadable, corrupt, or key-mismatched. */
|
|
59
|
+
export class SecretStoreCorruptError extends Error {
|
|
60
|
+
constructor(message) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "SecretStoreCorruptError";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Paths
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
/** Path to the AES-256 key file (32 random bytes). Lazily computed — see `getConfigDir`. */
|
|
69
|
+
export function getSecretKeyPath() {
|
|
70
|
+
return join(getConfigDir(), "secrets.key");
|
|
71
|
+
}
|
|
72
|
+
/** Path to the AES-256-GCM encrypted store. Lazily computed — see `getConfigDir`. */
|
|
73
|
+
export function getSecretDataPath() {
|
|
74
|
+
return join(getConfigDir(), "secrets.json.enc");
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Validation / normalization
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Normalize a secret name per the shared contract: trim surrounding
|
|
81
|
+
* whitespace, uppercase, then validate against `SECRET_NAME_PATTERN`.
|
|
82
|
+
* Throws `SecretNameError` with a generic message — the offending raw input
|
|
83
|
+
* is never echoed back, so it can't leak into logs.
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeSecretName(raw) {
|
|
86
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
87
|
+
throw new SecretNameError("Secret name is required");
|
|
88
|
+
}
|
|
89
|
+
const name = raw.trim().toUpperCase();
|
|
90
|
+
if (!SECRET_NAME_PATTERN.test(name)) {
|
|
91
|
+
throw new SecretNameError("Invalid secret name: must start with a letter, contain only letters, digits and underscores, max 64 characters (pattern ^[A-Z][A-Z0-9_]{0,63}$)");
|
|
92
|
+
}
|
|
93
|
+
return name;
|
|
94
|
+
}
|
|
95
|
+
function assertValueSize(value) {
|
|
96
|
+
if (typeof value !== "string") {
|
|
97
|
+
throw new SecretValueError("Secret value must be a string");
|
|
98
|
+
}
|
|
99
|
+
if (Buffer.byteLength(value, "utf8") > MAX_SECRET_VALUE_BYTES) {
|
|
100
|
+
throw new SecretValueError(`Secret value too large: max ${MAX_SECRET_VALUE_BYTES} bytes (64 KiB)`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Crypto + file I/O
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
function encrypt(data, key) {
|
|
107
|
+
const iv = randomBytes(IV_LENGTH);
|
|
108
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
109
|
+
const ciphertext = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
|
|
110
|
+
const tag = cipher.getAuthTag();
|
|
111
|
+
return Buffer.concat([MAGIC, iv, tag, ciphertext]);
|
|
112
|
+
}
|
|
113
|
+
function decrypt(buf, key) {
|
|
114
|
+
if (buf.length < MAGIC.length + IV_LENGTH + TAG_LENGTH ||
|
|
115
|
+
!buf.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
116
|
+
throw new SecretStoreCorruptError("Unrecognized secret store format");
|
|
117
|
+
}
|
|
118
|
+
const iv = buf.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
|
119
|
+
const tag = buf.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + TAG_LENGTH);
|
|
120
|
+
const ciphertext = buf.subarray(MAGIC.length + IV_LENGTH + TAG_LENGTH);
|
|
121
|
+
try {
|
|
122
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
123
|
+
decipher.setAuthTag(tag);
|
|
124
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new SecretStoreCorruptError("Secret store is corrupt or was encrypted with a different key");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Read the EXISTING key file. Throws `SecretStoreCorruptError` when the key
|
|
132
|
+
* is missing or has the wrong size. Deliberately NEVER generates a key: key
|
|
133
|
+
* generation happens on write paths only (`ensureSecretKey`), so a read
|
|
134
|
+
* against encrypted data without a key surfaces the lost-key state instead of
|
|
135
|
+
* silently minting a fresh key that can never decrypt the store.
|
|
136
|
+
*/
|
|
137
|
+
function readSecretKey() {
|
|
138
|
+
const keyPath = getSecretKeyPath();
|
|
139
|
+
let key;
|
|
140
|
+
try {
|
|
141
|
+
key = readFileSync(keyPath);
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err.code === "ENOENT") {
|
|
145
|
+
throw new SecretStoreCorruptError("Secrets key file is missing but encrypted data exists: restore secrets.key or delete the store and re-enter your secrets");
|
|
146
|
+
}
|
|
147
|
+
throw new SecretStoreCorruptError(`Cannot read secrets key: ${err instanceof Error ? err.message : String(err)}`);
|
|
148
|
+
}
|
|
149
|
+
if (key.length !== KEY_LENGTH) {
|
|
150
|
+
throw new SecretStoreCorruptError(`Secrets key file is corrupt (expected ${KEY_LENGTH} bytes): delete it and re-enter your secrets`);
|
|
151
|
+
}
|
|
152
|
+
return key;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Return the AES-256 key, generating a new one ONLY when the key file does
|
|
156
|
+
* not exist yet (write paths). An existing key with loose permissions is
|
|
157
|
+
* tightened back to 0600 (defense-in-depth).
|
|
158
|
+
*/
|
|
159
|
+
function ensureSecretKey() {
|
|
160
|
+
const keyPath = getSecretKeyPath();
|
|
161
|
+
mkdirSync(dirname(keyPath), { recursive: true, mode: 0o700 });
|
|
162
|
+
if (existsSync(keyPath)) {
|
|
163
|
+
try {
|
|
164
|
+
chmodSync(keyPath, 0o600);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Best-effort: some filesystems do not support chmod.
|
|
168
|
+
}
|
|
169
|
+
return readSecretKey();
|
|
170
|
+
}
|
|
171
|
+
const key = randomBytes(KEY_LENGTH);
|
|
172
|
+
writeFileSync(keyPath, key, { mode: 0o600 });
|
|
173
|
+
return key;
|
|
174
|
+
}
|
|
175
|
+
function emptyStoreData() {
|
|
176
|
+
return { version: 1, secrets: {} };
|
|
177
|
+
}
|
|
178
|
+
/** Read + decrypt the store. Returns an empty store when the file is missing. Throws on corruption. */
|
|
179
|
+
function readStoreData() {
|
|
180
|
+
const dataPath = getSecretDataPath();
|
|
181
|
+
if (!existsSync(dataPath))
|
|
182
|
+
return emptyStoreData();
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = readFileSync(dataPath);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
throw new SecretStoreCorruptError(`Cannot read secret store: ${err instanceof Error ? err.message : String(err)}`);
|
|
189
|
+
}
|
|
190
|
+
// The read path NEVER generates a key: encrypted data without a key is a
|
|
191
|
+
// lost-key state, and minting a fresh key here would mask it (and touch
|
|
192
|
+
// the filesystem as a side effect of a read). Keys are generated on write
|
|
193
|
+
// paths only (ensureSecretKey via writeStoreData).
|
|
194
|
+
const key = readSecretKey();
|
|
195
|
+
const json = decrypt(raw, key);
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(json);
|
|
198
|
+
if (parsed.version !== 1 || typeof parsed.secrets !== "object" || parsed.secrets === null) {
|
|
199
|
+
throw new SecretStoreCorruptError("Unrecognized secret store payload");
|
|
200
|
+
}
|
|
201
|
+
return { version: 1, secrets: parsed.secrets };
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
if (err instanceof SecretStoreCorruptError)
|
|
205
|
+
throw err;
|
|
206
|
+
throw new SecretStoreCorruptError("Unrecognized secret store payload");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Orphaned `.tmp-*` files older than this are removed on the next write. */
|
|
210
|
+
const STALE_TMP_MS = 60_000;
|
|
211
|
+
/**
|
|
212
|
+
* Remove orphaned `secrets.json.enc.tmp-*` files left behind by crashed
|
|
213
|
+
* writers. Only files older than `STALE_TMP_MS` are removed so a concurrent
|
|
214
|
+
* process's in-flight write (tmp file created moments ago) is never deleted.
|
|
215
|
+
*/
|
|
216
|
+
function cleanupStaleTmpFiles(dataPath) {
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = readdirSync(dirname(dataPath));
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const prefix = `${basename(dataPath)}.tmp-`;
|
|
225
|
+
const cutoff = Date.now() - STALE_TMP_MS;
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (!entry.startsWith(prefix))
|
|
228
|
+
continue;
|
|
229
|
+
const full = join(dirname(dataPath), entry);
|
|
230
|
+
try {
|
|
231
|
+
if (statSync(full).mtimeMs < cutoff)
|
|
232
|
+
unlinkSync(full);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// Ignore: the file vanished (another writer renamed it away).
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Encrypt + atomically write the store (tmp file + rename), mode 0600. */
|
|
240
|
+
function writeStoreData(data) {
|
|
241
|
+
const dataPath = getSecretDataPath();
|
|
242
|
+
const key = ensureSecretKey();
|
|
243
|
+
const payload = encrypt(JSON.stringify(data), key);
|
|
244
|
+
// Unique per-request tmp name: pid alone is shared by every write this
|
|
245
|
+
// process makes, so a crashed write would make the next write silently
|
|
246
|
+
// reuse (and clobber) the orphan. A random hex suffix makes collisions
|
|
247
|
+
// between concurrent writers practically impossible.
|
|
248
|
+
const tmpPath = `${dataPath}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
249
|
+
writeFileSync(tmpPath, payload, { mode: 0o600 });
|
|
250
|
+
try {
|
|
251
|
+
renameSync(tmpPath, dataPath);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
// Best-effort: remove this request's tmp file so failed writes cannot
|
|
255
|
+
// accumulate orphans.
|
|
256
|
+
try {
|
|
257
|
+
unlinkSync(tmpPath);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
// Already gone.
|
|
261
|
+
}
|
|
262
|
+
throw err;
|
|
263
|
+
}
|
|
264
|
+
// Defense-in-depth: a pre-existing file with loose permissions gets tightened.
|
|
265
|
+
chmodSync(dataPath, 0o600);
|
|
266
|
+
cleanupStaleTmpFiles(dataPath);
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// mtime-cached env injection
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* Cache keyed on the data file's `mtimeMs:size` so external writes (other CLI
|
|
273
|
+
* processes, the relay API) invalidate it without a restart. Our own write
|
|
274
|
+
* path (`setSecret`/`deleteSecret`) refreshes the cache explicitly, covering
|
|
275
|
+
* same-millisecond writes where the mtime would not change.
|
|
276
|
+
*/
|
|
277
|
+
let envCache = null;
|
|
278
|
+
function dataFileCacheKey() {
|
|
279
|
+
try {
|
|
280
|
+
const data = statSync(getSecretDataPath());
|
|
281
|
+
const key = statSync(getSecretKeyPath());
|
|
282
|
+
return `${data.mtimeMs}:${data.size}:${key.mtimeMs}:${key.size}`;
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function refreshEnvCache() {
|
|
289
|
+
const cacheKey = dataFileCacheKey();
|
|
290
|
+
const data = readStoreData();
|
|
291
|
+
// PATH is reserved: a secret named PATH would override the shell PATH that
|
|
292
|
+
// getShellEnv() deliberately rebuilds (bin dir injection) and break the
|
|
293
|
+
// bash tool. Stored under that name, it is ignored for env injection only.
|
|
294
|
+
const env = {};
|
|
295
|
+
for (const [name, record] of Object.entries(data.secrets)) {
|
|
296
|
+
if (name.toLowerCase() === "path")
|
|
297
|
+
continue;
|
|
298
|
+
env[name] = record.value;
|
|
299
|
+
}
|
|
300
|
+
envCache = { key: cacheKey ?? "empty", env };
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Return the secret env vars (name → value) for injection into the agent
|
|
304
|
+
* shell environment. Synchronous, mtime-cached: new/updated/deleted secrets
|
|
305
|
+
* are picked up on the next call without a CLI restart.
|
|
306
|
+
*
|
|
307
|
+
* Failure modes degrade gracefully — a corrupt store, missing key, or unreadable
|
|
308
|
+
* file returns `{}` instead of throwing, because `getShellEnv()` is sync and
|
|
309
|
+
* must never break bash spawning. No values are ever logged.
|
|
310
|
+
*/
|
|
311
|
+
export function getSecretEnvVars() {
|
|
312
|
+
try {
|
|
313
|
+
const cacheKey = dataFileCacheKey();
|
|
314
|
+
if (cacheKey === null) {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
if (envCache && envCache.key === cacheKey) {
|
|
318
|
+
return envCache.env;
|
|
319
|
+
}
|
|
320
|
+
refreshEnvCache();
|
|
321
|
+
return envCache?.env ?? {};
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return {};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Public API
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
/**
|
|
331
|
+
* Create or update a secret. Returns the metadata-only view (never the value).
|
|
332
|
+
* Throws `SecretNameError` for invalid names, `SecretValueError` for oversized
|
|
333
|
+
* values, and `SecretStoreCorruptError` when the store cannot be read/written.
|
|
334
|
+
*
|
|
335
|
+
* Fully synchronous (no awaits): the read-modify-write below is atomic per
|
|
336
|
+
* event-loop turn, so concurrent relay POSTs in one process can never
|
|
337
|
+
* interleave — no mutex is needed. Cross-process writers are isolated by the
|
|
338
|
+
* unique per-request tmp name + rename in `writeStoreData`; the last rename
|
|
339
|
+
* wins and every writer re-reads the store on its next call.
|
|
340
|
+
*/
|
|
341
|
+
export function setSecret(rawName, value) {
|
|
342
|
+
const name = normalizeSecretName(rawName);
|
|
343
|
+
assertValueSize(value);
|
|
344
|
+
const data = readStoreData();
|
|
345
|
+
const now = new Date().toISOString();
|
|
346
|
+
const existing = data.secrets[name];
|
|
347
|
+
data.secrets[name] = {
|
|
348
|
+
value,
|
|
349
|
+
createdAt: existing?.createdAt ?? now,
|
|
350
|
+
updatedAt: now,
|
|
351
|
+
};
|
|
352
|
+
writeStoreData(data);
|
|
353
|
+
refreshEnvCache();
|
|
354
|
+
return { name, createdAt: data.secrets[name].createdAt, updatedAt: now };
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Delete a secret. Returns true when a record was removed, false when the
|
|
358
|
+
* name was not stored. Throws `SecretNameError` for invalid names.
|
|
359
|
+
*/
|
|
360
|
+
export function deleteSecret(rawName) {
|
|
361
|
+
const name = normalizeSecretName(rawName);
|
|
362
|
+
const data = readStoreData();
|
|
363
|
+
if (!Object.prototype.hasOwnProperty.call(data.secrets, name)) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
delete data.secrets[name];
|
|
367
|
+
writeStoreData(data);
|
|
368
|
+
refreshEnvCache();
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* List secrets as metadata only — `{ name, createdAt, updatedAt }`. Values are
|
|
373
|
+
* never part of the returned shape (this is the source for the relay GET
|
|
374
|
+
* response and the `list_secrets` agent tool).
|
|
375
|
+
*/
|
|
376
|
+
export function listSecrets() {
|
|
377
|
+
const data = readStoreData();
|
|
378
|
+
return Object.entries(data.secrets)
|
|
379
|
+
.map(([name, record]) => ({
|
|
380
|
+
name,
|
|
381
|
+
createdAt: record.createdAt,
|
|
382
|
+
updatedAt: record.updatedAt,
|
|
383
|
+
}))
|
|
384
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* INTERNAL — read a single secret value. Used ONLY for env-var injection via
|
|
388
|
+
* `getSecretEnvVars()`. Deliberately not re-exported from `./index.js` so it
|
|
389
|
+
* cannot be reached from the relay handlers or the LLM-facing tool surface.
|
|
390
|
+
* Never log the return value.
|
|
391
|
+
*/
|
|
392
|
+
export function getSecretValue(rawName) {
|
|
393
|
+
const name = normalizeSecretName(rawName);
|
|
394
|
+
const data = readStoreData();
|
|
395
|
+
return data.secrets[name]?.value;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Remove the store + key (test helper and future "reset secrets" support).
|
|
399
|
+
* Returns true when either file was deleted.
|
|
400
|
+
*/
|
|
401
|
+
export function resetSecretStore() {
|
|
402
|
+
let removed = false;
|
|
403
|
+
for (const path of [getSecretDataPath(), getSecretKeyPath()]) {
|
|
404
|
+
try {
|
|
405
|
+
unlinkSync(path);
|
|
406
|
+
removed = true;
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
if (err.code !== "ENOENT")
|
|
410
|
+
throw err;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
envCache = null;
|
|
414
|
+
return removed;
|
|
415
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAaH,OAAO,EAQL,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACtB,MAAM,8BAA8B,CAAC;AAatC,OAAO,EAEL,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"agent-bridge.d.ts","sourceRoot":"","sources":["../../src/server/agent-bridge.ts"],"names":[],"mappings":"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"}
|
|
@@ -63,9 +63,11 @@ import { BrowserService } from "../extensions/browser/browser-service.js";
|
|
|
63
63
|
import subagentExt from "../agent/index.js";
|
|
64
64
|
import designerExtension from "../designer/index.js";
|
|
65
65
|
import logicTraceExtension from "../extensions/logic-trace.js";
|
|
66
|
+
import listSecretsExtension from "../extensions/list-secrets.js";
|
|
66
67
|
import observationalMemory from "../memory/index.js";
|
|
67
68
|
import { OBSERVATIONAL_MEMORY_CONTEXT_CUSTOM_TYPE, OBSERVATIONAL_MEMORY_SNAPSHOT_CUSTOM_TYPE, } from "../memory/types.js";
|
|
68
69
|
import { fetchAllowedModels as defaultFetchAllowedModels, } from "../relay/models-fetch.js";
|
|
70
|
+
import { annotatedUserMessageText } from "./attachments/persist.js";
|
|
69
71
|
import { humanizeProviderError } from "./error-humanizer.js";
|
|
70
72
|
import { readStudioBinding, getBindingFilePath } from "../studio-binding.js";
|
|
71
73
|
// Facade imports: cohesive helpers extracted verbatim to `./agent-bridge/` modules.
|
|
@@ -368,6 +370,12 @@ export class AgentBridge {
|
|
|
368
370
|
async (ext) => {
|
|
369
371
|
observationalMemory(ext);
|
|
370
372
|
},
|
|
373
|
+
// list_secrets: metadata-only view of the local encrypted secret store
|
|
374
|
+
// (names + timestamps). Values are never returned to the LLM — they are
|
|
375
|
+
// injected as env vars into the bash tool via getShellEnv().
|
|
376
|
+
async (ext) => {
|
|
377
|
+
listSecretsExtension(ext);
|
|
378
|
+
},
|
|
371
379
|
];
|
|
372
380
|
// Always register subagent tool — execute() checks settings from disk
|
|
373
381
|
// on every invocation, so toggling takes effect immediately (no restart).
|
|
@@ -432,9 +440,19 @@ export class AgentBridge {
|
|
|
432
440
|
const imageBlocks = (msg.images ?? [])
|
|
433
441
|
.map(toImageContent)
|
|
434
442
|
.filter((block) => block !== null);
|
|
443
|
+
// Attachment persistence (Option A): annotate rehydrated user
|
|
444
|
+
// messages whose attachments are still on disk, so the LLM sees
|
|
445
|
+
// the same saved-at hint the original prompt carried. Attachments
|
|
446
|
+
// that were swept (or never persisted) leave the text untouched.
|
|
447
|
+
const text = annotatedUserMessageText({
|
|
448
|
+
cwd: this.opts.cwd,
|
|
449
|
+
sessionId: this.opts.sessionId,
|
|
450
|
+
content: msg.content,
|
|
451
|
+
images: msg.images,
|
|
452
|
+
});
|
|
435
453
|
const content = imageBlocks.length > 0
|
|
436
|
-
? [...imageBlocks, { type: "text", text
|
|
437
|
-
:
|
|
454
|
+
? [...imageBlocks, { type: "text", text }]
|
|
455
|
+
: text;
|
|
438
456
|
sessionManager.appendMessage({
|
|
439
457
|
role: "user",
|
|
440
458
|
content,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL GC for persisted chat attachments (`.spectral/attachments/<id>/`).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/server/offload-gc.ts`: the sweep removes per-session
|
|
5
|
+
* directories whose session no longer exists AND whose directory mtime is
|
|
6
|
+
* older than `SPECTRAL_ATTACHMENT_TTL_DAYS` (default 7). Sessions that still
|
|
7
|
+
* exist in the store are never swept, however old their attachments are.
|
|
8
|
+
*
|
|
9
|
+
* Best-effort and synchronous: callers fire-and-forget it (server startup,
|
|
10
|
+
* first prompt). Never throws.
|
|
11
|
+
*
|
|
12
|
+
* @module @spectral/server/attachments/cleanup
|
|
13
|
+
*/
|
|
14
|
+
/** Session dirs older than this (in days) are swept when their session is gone. */
|
|
15
|
+
export declare const DEFAULT_ATTACHMENT_TTL_DAYS = 7;
|
|
16
|
+
/** Effective TTL in ms (override via `SPECTRAL_ATTACHMENT_TTL_DAYS`). */
|
|
17
|
+
export declare function getAttachmentTtlMs(): number;
|
|
18
|
+
export interface AttachmentSweepResult {
|
|
19
|
+
removedSessions: number;
|
|
20
|
+
removedBytes: number;
|
|
21
|
+
}
|
|
22
|
+
export interface SweepAttachmentsOptions {
|
|
23
|
+
/** `.spectral/attachments` root to sweep (per project cwd). */
|
|
24
|
+
root: string;
|
|
25
|
+
/**
|
|
26
|
+
* Session ids that still exist — their directories are NEVER swept,
|
|
27
|
+
* however old. Omitted = treat every session as gone (TTL-only sweep).
|
|
28
|
+
*/
|
|
29
|
+
activeSessionIds?: Iterable<string>;
|
|
30
|
+
/** Epoch ms "now" (tests). Defaults to `Date.now()`. */
|
|
31
|
+
now?: number;
|
|
32
|
+
/** TTL override in ms (tests). Defaults to `getAttachmentTtlMs()`. */
|
|
33
|
+
ttlMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Remove stale attachment session directories. A directory is removed only
|
|
37
|
+
* when BOTH: its name matches no active session id AND its mtime is older
|
|
38
|
+
* than the TTL. Dotfiles (`.gitignore`) and stray non-directories are left
|
|
39
|
+
* alone. Never throws.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sweepAttachments(options: SweepAttachmentsOptions): AttachmentSweepResult;
|
|
42
|
+
//# sourceMappingURL=cleanup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleanup.d.ts","sourceRoot":"","sources":["../../../src/server/attachments/cleanup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,mFAAmF;AACnF,eAAO,MAAM,2BAA2B,IAAI,CAAC;AAE7C,yEAAyE;AACzE,wBAAgB,kBAAkB,IAAI,MAAM,CAG3C;AAsBD,MAAM,WAAW,qBAAqB;IACpC,eAAe,EAAE,MAAM,CAAC;IACxB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,uBAAuB;IACtC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb;;;OAGG;IACH,gBAAgB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;IACpC,wDAAwD;IACxD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,uBAAuB,GAAG,qBAAqB,CAuCxF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL GC for persisted chat attachments (`.spectral/attachments/<id>/`).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `src/server/offload-gc.ts`: the sweep removes per-session
|
|
5
|
+
* directories whose session no longer exists AND whose directory mtime is
|
|
6
|
+
* older than `SPECTRAL_ATTACHMENT_TTL_DAYS` (default 7). Sessions that still
|
|
7
|
+
* exist in the store are never swept, however old their attachments are.
|
|
8
|
+
*
|
|
9
|
+
* Best-effort and synchronous: callers fire-and-forget it (server startup,
|
|
10
|
+
* first prompt). Never throws.
|
|
11
|
+
*
|
|
12
|
+
* @module @spectral/server/attachments/cleanup
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { envPositiveInt } from "../../sdk/coding-agent/core/tools/offload.js";
|
|
17
|
+
/** Session dirs older than this (in days) are swept when their session is gone. */
|
|
18
|
+
export const DEFAULT_ATTACHMENT_TTL_DAYS = 7;
|
|
19
|
+
/** Effective TTL in ms (override via `SPECTRAL_ATTACHMENT_TTL_DAYS`). */
|
|
20
|
+
export function getAttachmentTtlMs() {
|
|
21
|
+
return envPositiveInt("SPECTRAL_ATTACHMENT_TTL_DAYS", DEFAULT_ATTACHMENT_TTL_DAYS) *
|
|
22
|
+
24 * 60 * 60 * 1000;
|
|
23
|
+
}
|
|
24
|
+
function dirSizeBytes(dir) {
|
|
25
|
+
let total = 0;
|
|
26
|
+
let entries;
|
|
27
|
+
try {
|
|
28
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const full = join(dir, entry.name);
|
|
35
|
+
try {
|
|
36
|
+
if (entry.isDirectory())
|
|
37
|
+
total += dirSizeBytes(full);
|
|
38
|
+
else if (entry.isFile())
|
|
39
|
+
total += statSync(full).size;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// Skip unreadable entries — GC is best-effort.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return total;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Remove stale attachment session directories. A directory is removed only
|
|
49
|
+
* when BOTH: its name matches no active session id AND its mtime is older
|
|
50
|
+
* than the TTL. Dotfiles (`.gitignore`) and stray non-directories are left
|
|
51
|
+
* alone. Never throws.
|
|
52
|
+
*/
|
|
53
|
+
export function sweepAttachments(options) {
|
|
54
|
+
const result = { removedSessions: 0, removedBytes: 0 };
|
|
55
|
+
const { root } = options;
|
|
56
|
+
if (!root || !existsSync(root))
|
|
57
|
+
return result;
|
|
58
|
+
const active = new Set(options.activeSessionIds ?? []);
|
|
59
|
+
const ttlMs = options.ttlMs ?? getAttachmentTtlMs();
|
|
60
|
+
const now = options.now ?? Date.now();
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
// Only sweep session directories; leave `.gitignore` and strays alone.
|
|
70
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
71
|
+
continue;
|
|
72
|
+
if (active.has(entry.name))
|
|
73
|
+
continue;
|
|
74
|
+
const dir = join(root, entry.name);
|
|
75
|
+
let mtimeMs;
|
|
76
|
+
try {
|
|
77
|
+
const stat = statSync(dir);
|
|
78
|
+
if (!stat.isDirectory())
|
|
79
|
+
continue;
|
|
80
|
+
mtimeMs = stat.mtimeMs;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// `stat.mtimeMs` carries sub-ms precision while `Date.now()` does not —
|
|
86
|
+
// floor it so a directory created in the same millisecond as `now`
|
|
87
|
+
// (ttlMs = 0) is deterministically "older".
|
|
88
|
+
if (now - Math.floor(mtimeMs) < ttlMs)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
result.removedBytes += dirSizeBytes(dir);
|
|
92
|
+
rmSync(dir, { recursive: true, force: true });
|
|
93
|
+
result.removedSessions += 1;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Best-effort.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|