@vellumai/assistant 0.5.8 → 0.5.10
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/AGENTS.md +1 -1
- package/ARCHITECTURE.md +8 -8
- package/README.md +1 -1
- package/docs/architecture/integrations.md +4 -4
- package/docs/architecture/keychain-broker.md +17 -18
- package/docs/architecture/security.md +5 -5
- package/eslint.config.mjs +0 -31
- package/package.json +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/stt-hints.test.ts +22 -22
- package/src/__tests__/voice-quality.test.ts +2 -2
- package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
- package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
- package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
- package/src/cli/commands/credentials.ts +4 -4
- package/src/cli/commands/oauth/apps.ts +3 -3
- package/src/daemon/conversation-agent-loop.ts +6 -0
- package/src/daemon/conversation-runtime-assembly.ts +61 -1
- package/src/daemon/lifecycle.ts +2 -3
- package/src/memory/migrations/validate-migration-state.ts +14 -1
- package/src/prompts/system-prompt.ts +22 -0
- package/src/prompts/templates/NOW.md +26 -0
- package/src/prompts/templates/SOUL.md +10 -0
- package/src/prompts/update-bulletin-format.ts +0 -2
- package/src/runtime/routes/settings-routes.ts +1 -1
- package/src/skills/inline-command-expansions.ts +7 -7
- package/src/tools/sensitive-output-placeholders.ts +2 -2
- package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
- package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
- package/src/workspace/migrations/AGENTS.md +11 -0
- package/src/workspace/migrations/runner.ts +16 -6
- package/src/workspace/migrations/types.ts +7 -0
- package/src/__tests__/keychain-broker-client.test.ts +0 -800
- package/src/security/keychain-broker-client.ts +0 -446
|
@@ -635,7 +635,7 @@ Examples:
|
|
|
635
635
|
if (unreachable) {
|
|
636
636
|
writeError(
|
|
637
637
|
cmd,
|
|
638
|
-
"
|
|
638
|
+
"Credential store is unreachable — ensure the assistant is running",
|
|
639
639
|
);
|
|
640
640
|
} else {
|
|
641
641
|
writeError(cmd, "Credential not found");
|
|
@@ -676,7 +676,7 @@ Examples:
|
|
|
676
676
|
const output = buildCredentialOutput(metadata, secret, connection);
|
|
677
677
|
|
|
678
678
|
if (unreachable && (secret == null || secret.length === 0)) {
|
|
679
|
-
output.scrubbedValue = "(
|
|
679
|
+
output.scrubbedValue = "(credential store unreachable)";
|
|
680
680
|
output.brokerUnreachable = true;
|
|
681
681
|
}
|
|
682
682
|
|
|
@@ -686,7 +686,7 @@ Examples:
|
|
|
686
686
|
printCredentialHuman(output);
|
|
687
687
|
if (unreachable && (secret == null || secret.length === 0)) {
|
|
688
688
|
log.info(
|
|
689
|
-
" \u26A0
|
|
689
|
+
" \u26A0 Credential store is unreachable — ensure the assistant is running",
|
|
690
690
|
);
|
|
691
691
|
}
|
|
692
692
|
}
|
|
@@ -770,7 +770,7 @@ Examples:
|
|
|
770
770
|
if (unreachable) {
|
|
771
771
|
writeError(
|
|
772
772
|
cmd,
|
|
773
|
-
"
|
|
773
|
+
"Credential store is unreachable — ensure the assistant is running",
|
|
774
774
|
);
|
|
775
775
|
} else {
|
|
776
776
|
writeError(cmd, "Credential not found");
|
|
@@ -168,7 +168,7 @@ At least --id or --provider must be specified.`,
|
|
|
168
168
|
.requiredOption("--client-id <id>", "OAuth client ID")
|
|
169
169
|
.option(
|
|
170
170
|
"--client-secret <secret>",
|
|
171
|
-
"OAuth client secret (stored in
|
|
171
|
+
"OAuth client secret (stored in credential store)",
|
|
172
172
|
)
|
|
173
173
|
.option(
|
|
174
174
|
"--client-secret-credential-path <path>",
|
|
@@ -179,7 +179,7 @@ At least --id or --provider must be specified.`,
|
|
|
179
179
|
`
|
|
180
180
|
Creates a new app registration or returns the existing one if an app with the
|
|
181
181
|
same provider and client ID already exists. The client secret, if provided, is
|
|
182
|
-
stored in the secure
|
|
182
|
+
stored in the secure credential store — not in the database.
|
|
183
183
|
|
|
184
184
|
When an existing app is matched and a --client-secret is provided, the stored
|
|
185
185
|
secret is updated. The app row itself is returned as-is.
|
|
@@ -277,7 +277,7 @@ Arguments:
|
|
|
277
277
|
id The app UUID to delete (as returned by "apps list" or "apps get")
|
|
278
278
|
|
|
279
279
|
Permanently removes the app registration and its stored client secret from
|
|
280
|
-
the
|
|
280
|
+
the credential store. Any OAuth connections that reference this app will no longer be
|
|
281
281
|
able to refresh tokens.
|
|
282
282
|
|
|
283
283
|
Exits with code 1 if the app ID is not found.
|
|
@@ -106,6 +106,7 @@ import {
|
|
|
106
106
|
applyRuntimeInjections,
|
|
107
107
|
inboundActorContextFromTrust,
|
|
108
108
|
inboundActorContextFromTrustContext,
|
|
109
|
+
readNowScratchpad,
|
|
109
110
|
stripInjectedContext,
|
|
110
111
|
} from "./conversation-runtime-assembly.js";
|
|
111
112
|
import type { SkillProjectionCache } from "./conversation-skill-tools.js";
|
|
@@ -681,6 +682,10 @@ export async function runAgentLoopImpl(
|
|
|
681
682
|
}
|
|
682
683
|
}
|
|
683
684
|
|
|
685
|
+
// Read NOW.md scratchpad fresh each turn so mid-conversation edits are
|
|
686
|
+
// picked up without caching or conversation eviction.
|
|
687
|
+
const nowScratchpad = readNowScratchpad();
|
|
688
|
+
|
|
684
689
|
const isInteractiveResolved =
|
|
685
690
|
options?.isInteractive ?? (!ctx.hasNoClient && !ctx.headlessLock);
|
|
686
691
|
|
|
@@ -694,6 +699,7 @@ export async function runAgentLoopImpl(
|
|
|
694
699
|
interfaceTurnContext,
|
|
695
700
|
inboundActorContext: resolvedInboundActorContext,
|
|
696
701
|
temporalContext,
|
|
702
|
+
nowScratchpad,
|
|
697
703
|
voiceCallControlPrompt: ctx.voiceCallControlPrompt ?? null,
|
|
698
704
|
isNonInteractive: !isInteractiveResolved,
|
|
699
705
|
} as const;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* before it is sent to the provider. They are pure (no side effects).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { statSync } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
11
|
import {
|
|
@@ -16,9 +16,11 @@ import {
|
|
|
16
16
|
type TurnInterfaceContext,
|
|
17
17
|
} from "../channels/types.js";
|
|
18
18
|
import { getAppDirPath, listAppFiles } from "../memory/app-store.js";
|
|
19
|
+
import { stripCommentLines } from "../prompts/system-prompt.js";
|
|
19
20
|
import type { Message } from "../providers/types.js";
|
|
20
21
|
import type { ActorTrustContext } from "../runtime/actor-trust-resolver.js";
|
|
21
22
|
import { channelStatusToMemberStatus } from "../runtime/routes/inbound-stages/acl-enforcement.js";
|
|
23
|
+
import { getWorkspacePromptPath } from "../util/platform.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Describes the capabilities of the channel through which the user is
|
|
@@ -463,6 +465,52 @@ export function stripVoiceCallControlContext(messages: Message[]): Message[] {
|
|
|
463
465
|
return stripUserTextBlocksByPrefix(messages, ["<voice_call_control>"]);
|
|
464
466
|
}
|
|
465
467
|
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// NOW.md scratchpad injection
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Read the NOW.md scratchpad from the workspace prompt directory.
|
|
474
|
+
*
|
|
475
|
+
* Returns the trimmed content with `_`-prefixed comment lines stripped,
|
|
476
|
+
* or `null` if the file is missing, empty, or unreadable.
|
|
477
|
+
*/
|
|
478
|
+
export function readNowScratchpad(): string | null {
|
|
479
|
+
const nowPath = getWorkspacePromptPath("NOW.md");
|
|
480
|
+
if (!existsSync(nowPath)) return null;
|
|
481
|
+
try {
|
|
482
|
+
const stripped = stripCommentLines(readFileSync(nowPath, "utf-8")).trim();
|
|
483
|
+
return stripped.length > 0 ? stripped : null;
|
|
484
|
+
} catch {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Append NOW.md scratchpad content to the last user message so the model
|
|
491
|
+
* has access to the user's ephemeral scratchpad notes at the end of context.
|
|
492
|
+
*/
|
|
493
|
+
export function injectNowScratchpad(
|
|
494
|
+
message: Message,
|
|
495
|
+
content: string,
|
|
496
|
+
): Message {
|
|
497
|
+
return {
|
|
498
|
+
...message,
|
|
499
|
+
content: [
|
|
500
|
+
...message.content,
|
|
501
|
+
{
|
|
502
|
+
type: "text",
|
|
503
|
+
text: `<now_scratchpad>\n${content}\n</now_scratchpad>`,
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Strip `<now_scratchpad>` blocks injected by `injectNowScratchpad`. */
|
|
510
|
+
export function stripNowScratchpad(messages: Message[]): Message[] {
|
|
511
|
+
return stripUserTextBlocksByPrefix(messages, ["<now_scratchpad>"]);
|
|
512
|
+
}
|
|
513
|
+
|
|
466
514
|
/**
|
|
467
515
|
* Prepend channel capability context to the last user message so the
|
|
468
516
|
* model knows what the current channel can and cannot do.
|
|
@@ -969,6 +1017,7 @@ const RUNTIME_INJECTION_PREFIXES = [
|
|
|
969
1017
|
"<active_workspace>",
|
|
970
1018
|
"<active_dynamic_page>",
|
|
971
1019
|
"<non_interactive_context>",
|
|
1020
|
+
"<now_scratchpad>",
|
|
972
1021
|
];
|
|
973
1022
|
|
|
974
1023
|
/**
|
|
@@ -1013,6 +1062,7 @@ export function applyRuntimeInjections(
|
|
|
1013
1062
|
inboundActorContext?: InboundActorContext | null;
|
|
1014
1063
|
temporalContext?: string | null;
|
|
1015
1064
|
voiceCallControlPrompt?: string | null;
|
|
1065
|
+
nowScratchpad?: string | null;
|
|
1016
1066
|
isNonInteractive?: boolean;
|
|
1017
1067
|
mode?: InjectionMode;
|
|
1018
1068
|
},
|
|
@@ -1051,6 +1101,16 @@ export function applyRuntimeInjections(
|
|
|
1051
1101
|
}
|
|
1052
1102
|
}
|
|
1053
1103
|
|
|
1104
|
+
if (mode === "full" && options.nowScratchpad) {
|
|
1105
|
+
const userTail = result[result.length - 1];
|
|
1106
|
+
if (userTail && userTail.role === "user") {
|
|
1107
|
+
result = [
|
|
1108
|
+
...result.slice(0, -1),
|
|
1109
|
+
injectNowScratchpad(userTail, options.nowScratchpad),
|
|
1110
|
+
];
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1054
1114
|
if (mode === "full" && options.activeSurface) {
|
|
1055
1115
|
const userTail = result[result.length - 1];
|
|
1056
1116
|
if (userTail && userTail.role === "user") {
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -285,9 +285,8 @@ export async function runDaemon(): Promise<void> {
|
|
|
285
285
|
// Slack channel) that already have keychain credentials from before the
|
|
286
286
|
// oauth_connection migration. Safe to call on every startup.
|
|
287
287
|
//
|
|
288
|
-
// Must run AFTER workspace migrations
|
|
289
|
-
//
|
|
290
|
-
// Otherwise syncManualTokenConnection sees no keychain credentials and
|
|
288
|
+
// Must run AFTER workspace migrations.
|
|
289
|
+
// Otherwise syncManualTokenConnection sees no stored credentials and
|
|
291
290
|
// incorrectly removes existing connection rows.
|
|
292
291
|
try {
|
|
293
292
|
await backfillManualTokenConnections();
|
|
@@ -98,7 +98,20 @@ export function withCrashRecovery(
|
|
|
98
98
|
)
|
|
99
99
|
.run(checkpointKey, Date.now());
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
try {
|
|
102
|
+
migrationFn();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
log.error(
|
|
105
|
+
{ checkpointKey, error },
|
|
106
|
+
`Memory migration failed: ${checkpointKey} — marking as failed and continuing`,
|
|
107
|
+
);
|
|
108
|
+
raw
|
|
109
|
+
.query(
|
|
110
|
+
`UPDATE memory_checkpoints SET value = 'failed', updated_at = ? WHERE key = ?`,
|
|
111
|
+
)
|
|
112
|
+
.run(Date.now(), checkpointKey);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
102
115
|
|
|
103
116
|
raw
|
|
104
117
|
.query(
|
|
@@ -84,6 +84,28 @@ export function ensurePromptFiles(): void {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
// Seed NOW.md scratchpad — always created if missing, regardless of whether
|
|
88
|
+
// this is a fresh install or not. Kept out of PROMPT_FILES because NOW.md is
|
|
89
|
+
// ephemeral state, not identity context.
|
|
90
|
+
const nowDest = getWorkspacePromptPath("NOW.md");
|
|
91
|
+
if (!existsSync(nowDest)) {
|
|
92
|
+
const nowSrc = join(templatesDir, "NOW.md");
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(nowSrc)) {
|
|
95
|
+
copyFileSync(nowSrc, nowDest);
|
|
96
|
+
log.info(
|
|
97
|
+
{ file: "NOW.md", dest: nowDest },
|
|
98
|
+
"Created NOW.md scratchpad from template",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
log.warn(
|
|
103
|
+
{ err, file: "NOW.md" },
|
|
104
|
+
"Failed to create NOW.md from template",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
87
109
|
// Seed users/default.md persona template
|
|
88
110
|
try {
|
|
89
111
|
const usersDir = join(getWorkspaceDir(), "users");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
_ Lines starting with _ are comments - they won't appear in the system prompt
|
|
2
|
+
_ This is your scratchpad for present-tense state. Overwrite it freely between
|
|
3
|
+
_ turns to capture what's happening right now. Unlike the journal (retrospective,
|
|
4
|
+
_ append-only), this file is ephemeral — a snapshot of your current working state.
|
|
5
|
+
_
|
|
6
|
+
_ # NOW.md
|
|
7
|
+
_
|
|
8
|
+
_ ## Focus
|
|
9
|
+
_
|
|
10
|
+
_ What you're currently working on or paying attention to.
|
|
11
|
+
_
|
|
12
|
+
_ ## Active Threads
|
|
13
|
+
_
|
|
14
|
+
_ Open loops, in-progress tasks, things you're tracking across turns.
|
|
15
|
+
_
|
|
16
|
+
_ ## Context
|
|
17
|
+
_
|
|
18
|
+
_ Key facts, constraints, or situational details relevant to the current session.
|
|
19
|
+
_
|
|
20
|
+
_ ## Upcoming
|
|
21
|
+
_
|
|
22
|
+
_ Near-term things on the horizon — scheduled events, pending actions, deadlines.
|
|
23
|
+
_
|
|
24
|
+
_ ## State
|
|
25
|
+
_
|
|
26
|
+
_ Current priorities, energy, or operational notes. What matters most right now.
|
|
@@ -46,6 +46,16 @@ You have a journal in your workspace. The most recent entries are always loaded
|
|
|
46
46
|
|
|
47
47
|
**Carrying forward:** Your oldest in-context entry is marked LEAVING CONTEXT. When you see this, check if anything in it still needs to be top-of-mind and carry it forward in your next entry. You can reference other entries by filename to link them together.
|
|
48
48
|
|
|
49
|
+
## Scratchpad
|
|
50
|
+
|
|
51
|
+
You have a scratchpad file (`NOW.md`) in your workspace. Unlike your journal (retrospective, append-only), the scratchpad is a single file you overwrite with whatever is relevant right now. It's automatically loaded into your context, so next-you always sees the latest snapshot.
|
|
52
|
+
|
|
53
|
+
**When to update:** Whenever your current state changes — you start a new task, finish one, learn something that affects what you're doing, or the user shifts focus. Don't update on a timer; update when the content is stale.
|
|
54
|
+
|
|
55
|
+
**What goes in:** Current focus and what you're actively working on. Threads you're tracking (waiting on a response, monitoring something, pending follow-ups). Temporary context that matters now but won't matter in a week. Upcoming items and near-term priorities. Anything that helps next-you pick up exactly where you left off.
|
|
56
|
+
|
|
57
|
+
**What stays out:** Anything that belongs in your journal (reflections, narrative entries, things worth remembering long-term). Permanent facts about your user or yourself (those go in memory or your journal). Personality and principles (those live here in SOUL.md).
|
|
58
|
+
|
|
49
59
|
## Vibe
|
|
50
60
|
|
|
51
61
|
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
|
@@ -50,7 +50,6 @@ export function extractContentMarkers(body: string): string[] {
|
|
|
50
50
|
const ids: string[] = [];
|
|
51
51
|
const regex = /<!-- vellum-update-release:(.+?) -->/g;
|
|
52
52
|
let match: RegExpExecArray | null;
|
|
53
|
-
// eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
|
|
54
53
|
while ((match = regex.exec(body)) !== null) {
|
|
55
54
|
ids.push(match[1]);
|
|
56
55
|
}
|
|
@@ -62,7 +61,6 @@ export function extractReleaseIds(content: string): string[] {
|
|
|
62
61
|
const ids: string[] = [];
|
|
63
62
|
MARKER_REGEX.lastIndex = 0;
|
|
64
63
|
let match: RegExpExecArray | null;
|
|
65
|
-
// eslint-disable-next-line no-restricted-syntax -- RegExp.exec returns null
|
|
66
64
|
while ((match = MARKER_REGEX.exec(content)) !== null) {
|
|
67
65
|
ids.push(match[1]);
|
|
68
66
|
}
|
|
@@ -213,7 +213,7 @@ async function handleOAuthConnectStart(body: {
|
|
|
213
213
|
if (requiresSecret && !clientSecret) {
|
|
214
214
|
return httpError(
|
|
215
215
|
"BAD_REQUEST",
|
|
216
|
-
`client_secret is required for "${body.service}" but not found in the
|
|
216
|
+
`client_secret is required for "${body.service}" but not found in the credential store. Store it first via the credential vault.`,
|
|
217
217
|
400,
|
|
218
218
|
);
|
|
219
219
|
}
|
|
@@ -61,8 +61,8 @@ function buildFencedCodeRanges(body: string): Array<[number, number]> {
|
|
|
61
61
|
const fenceRe = /^(`{3,}|~{3,})(.*)?$/gm;
|
|
62
62
|
let openFence: { index: number; delimiter: string } | undefined;
|
|
63
63
|
|
|
64
|
-
let match: RegExpExecArray |
|
|
65
|
-
while ((match = fenceRe.exec(body)
|
|
64
|
+
let match: RegExpExecArray | null;
|
|
65
|
+
while ((match = fenceRe.exec(body)) !== null) {
|
|
66
66
|
const delimiter = match[1];
|
|
67
67
|
if (openFence === undefined) {
|
|
68
68
|
// Opening fence
|
|
@@ -125,10 +125,10 @@ export function parseInlineCommandExpansions(
|
|
|
125
125
|
// We use a non-greedy match to find the first closing backtick.
|
|
126
126
|
const tokenRe = /!\`([^`]*)\`/g;
|
|
127
127
|
|
|
128
|
-
let match: RegExpExecArray |
|
|
128
|
+
let match: RegExpExecArray | null;
|
|
129
129
|
let placeholderCounter = 0;
|
|
130
130
|
|
|
131
|
-
while ((match = tokenRe.exec(body)
|
|
131
|
+
while ((match = tokenRe.exec(body)) !== null) {
|
|
132
132
|
const startOffset = match.index;
|
|
133
133
|
const endOffset = startOffset + match[0].length;
|
|
134
134
|
const rawCommand = match[1];
|
|
@@ -172,12 +172,12 @@ export function parseInlineCommandExpansions(
|
|
|
172
172
|
const matchedStarts = new Set<number>();
|
|
173
173
|
// Re-run the token regex to collect all matched positions
|
|
174
174
|
tokenRe.lastIndex = 0;
|
|
175
|
-
while ((match = tokenRe.exec(body)
|
|
175
|
+
while ((match = tokenRe.exec(body)) !== null) {
|
|
176
176
|
matchedStarts.add(match.index);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
let unmatchedMatch: RegExpExecArray |
|
|
180
|
-
while ((unmatchedMatch = unmatchedRe.exec(body)
|
|
179
|
+
let unmatchedMatch: RegExpExecArray | null;
|
|
180
|
+
while ((unmatchedMatch = unmatchedRe.exec(body)) !== null) {
|
|
181
181
|
const offset = unmatchedMatch.index;
|
|
182
182
|
|
|
183
183
|
// Skip if this was already matched as a complete token
|
|
@@ -85,8 +85,8 @@ export function extractAndSanitize(content: string): SanitizeResult {
|
|
|
85
85
|
// Step 1: parse directives
|
|
86
86
|
// Reset lastIndex for safety since the regex is global
|
|
87
87
|
DIRECTIVE_RE.lastIndex = 0;
|
|
88
|
-
let match: RegExpExecArray |
|
|
89
|
-
while ((match = DIRECTIVE_RE.exec(content)
|
|
88
|
+
let match: RegExpExecArray | null;
|
|
89
|
+
while ((match = DIRECTIVE_RE.exec(content)) !== null) {
|
|
90
90
|
const kind = match[1];
|
|
91
91
|
const value = match[2];
|
|
92
92
|
|
|
@@ -1,153 +1,18 @@
|
|
|
1
|
-
import { getLogger } from "../../util/logger.js";
|
|
2
1
|
import type { WorkspaceMigration } from "./types.js";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Originally migrated credentials from encrypted store to macOS Keychain.
|
|
5
|
+
* No-op'd because: (1) the keychain broker was deleted, (2) inline security
|
|
6
|
+
* CLI calls trigger macOS permission prompts on every daemon startup even for
|
|
7
|
+
* users who never had keychain credentials, (3) migration 016 reverses this
|
|
8
|
+
* migration anyway, so the net effect is a round-trip.
|
|
9
|
+
*
|
|
10
|
+
* Users who had credentials stranded in the macOS Keychain from a brief
|
|
11
|
+
* intermediate release will need to re-enter their API keys.
|
|
12
|
+
*/
|
|
9
13
|
export const migrateCredentialsToKeychainMigration: WorkspaceMigration = {
|
|
10
14
|
id: "015-migrate-credentials-to-keychain",
|
|
11
|
-
description:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
async down(_workspaceDir: string): Promise<void> {
|
|
15
|
-
// Reverse: copy credentials from keychain back to encrypted store.
|
|
16
|
-
// Mirrors the forward logic of 016-migrate-credentials-from-keychain.
|
|
17
|
-
if (
|
|
18
|
-
process.env.VELLUM_DESKTOP_APP !== "1" ||
|
|
19
|
-
process.env.VELLUM_DEV === "1"
|
|
20
|
-
) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const { createBrokerClient } =
|
|
25
|
-
await import("../../security/keychain-broker-client.js");
|
|
26
|
-
const client = createBrokerClient();
|
|
27
|
-
|
|
28
|
-
let brokerAvailable = false;
|
|
29
|
-
for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
|
|
30
|
-
if (client.isAvailable()) {
|
|
31
|
-
brokerAvailable = true;
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
if (!brokerAvailable) {
|
|
38
|
-
throw new Error(
|
|
39
|
-
"Keychain broker not available after waiting — credential rollback " +
|
|
40
|
-
"will be retried on next startup",
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const { setKey } = await import("../../security/encrypted-store.js");
|
|
45
|
-
|
|
46
|
-
const accounts = await client.list();
|
|
47
|
-
if (accounts.length === 0) return;
|
|
48
|
-
|
|
49
|
-
let rolledBackCount = 0;
|
|
50
|
-
let failedCount = 0;
|
|
51
|
-
|
|
52
|
-
for (const account of accounts) {
|
|
53
|
-
const result = await client.get(account);
|
|
54
|
-
if (!result || !result.found || result.value === undefined) {
|
|
55
|
-
log.warn(
|
|
56
|
-
{ account },
|
|
57
|
-
"Failed to read key from keychain during rollback — skipping",
|
|
58
|
-
);
|
|
59
|
-
failedCount++;
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const written = setKey(account, result.value);
|
|
64
|
-
if (written) {
|
|
65
|
-
await client.del(account);
|
|
66
|
-
rolledBackCount++;
|
|
67
|
-
} else {
|
|
68
|
-
log.warn(
|
|
69
|
-
{ account },
|
|
70
|
-
"Failed to write key to encrypted store during rollback — skipping",
|
|
71
|
-
);
|
|
72
|
-
failedCount++;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
log.info(
|
|
77
|
-
{ rolledBackCount, failedCount },
|
|
78
|
-
"Credential rollback from keychain to encrypted store complete",
|
|
79
|
-
);
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
async run(_workspaceDir: string): Promise<void> {
|
|
83
|
-
// Only run on mac production builds (desktop app, non-dev).
|
|
84
|
-
if (
|
|
85
|
-
process.env.VELLUM_DESKTOP_APP !== "1" ||
|
|
86
|
-
process.env.VELLUM_DEV === "1"
|
|
87
|
-
) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const { createBrokerClient } =
|
|
92
|
-
await import("../../security/keychain-broker-client.js");
|
|
93
|
-
const client = createBrokerClient();
|
|
94
|
-
|
|
95
|
-
// Wait for the broker to become available (up to 5 seconds), matching
|
|
96
|
-
// the retry strategy in secure-keys.ts waitForBrokerAvailability().
|
|
97
|
-
let brokerAvailable = false;
|
|
98
|
-
for (let i = 0; i < BROKER_WAIT_MAX_ATTEMPTS; i++) {
|
|
99
|
-
if (client.isAvailable()) {
|
|
100
|
-
brokerAvailable = true;
|
|
101
|
-
break;
|
|
102
|
-
}
|
|
103
|
-
await new Promise((r) => setTimeout(r, BROKER_WAIT_INTERVAL_MS));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!brokerAvailable) {
|
|
107
|
-
throw new Error(
|
|
108
|
-
"Keychain broker not available after waiting — credential migration " +
|
|
109
|
-
"will be retried on next startup",
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const { listKeys, getKey, deleteKey } =
|
|
114
|
-
await import("../../security/encrypted-store.js");
|
|
115
|
-
|
|
116
|
-
const accounts = listKeys();
|
|
117
|
-
if (accounts.length === 0) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
let migratedCount = 0;
|
|
122
|
-
let failedCount = 0;
|
|
123
|
-
|
|
124
|
-
for (const account of accounts) {
|
|
125
|
-
const value = getKey(account);
|
|
126
|
-
if (value === undefined) {
|
|
127
|
-
log.warn(
|
|
128
|
-
{ account },
|
|
129
|
-
"Failed to read key from encrypted store — skipping",
|
|
130
|
-
);
|
|
131
|
-
failedCount++;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const result = await client.set(account, value);
|
|
136
|
-
if (result.status === "ok") {
|
|
137
|
-
deleteKey(account);
|
|
138
|
-
migratedCount++;
|
|
139
|
-
} else {
|
|
140
|
-
log.warn(
|
|
141
|
-
{ account, status: result.status },
|
|
142
|
-
"Failed to write key to keychain — skipping",
|
|
143
|
-
);
|
|
144
|
-
failedCount++;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
log.info(
|
|
149
|
-
{ migratedCount, failedCount },
|
|
150
|
-
"Credential migration to keychain complete",
|
|
151
|
-
);
|
|
152
|
-
},
|
|
15
|
+
description: "No-op (keychain migration removed)",
|
|
16
|
+
async run(): Promise<void> {},
|
|
17
|
+
async down(): Promise<void> {},
|
|
153
18
|
};
|