@vellumai/assistant 0.4.13 → 0.4.15
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/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- package/src/runtime/middleware/actor-token.ts +0 -265
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
API_KEY_PROVIDERS,
|
|
3
|
+
getConfig,
|
|
4
|
+
invalidateConfigCache,
|
|
5
|
+
} from "../../config/loader.js";
|
|
6
|
+
import { initializeProviders } from "../../providers/registry.js";
|
|
7
|
+
import { deleteSecureKey, setSecureKey } from "../../security/secure-keys.js";
|
|
8
|
+
import {
|
|
9
|
+
assertMetadataWritable,
|
|
10
|
+
deleteCredentialMetadata,
|
|
11
|
+
upsertCredentialMetadata,
|
|
12
|
+
} from "../../tools/credentials/metadata-store.js";
|
|
13
|
+
import { getLogger } from "../../util/logger.js";
|
|
14
|
+
import { httpError } from "../http-errors.js";
|
|
7
15
|
|
|
8
|
-
const log = getLogger(
|
|
16
|
+
const log = getLogger("runtime-http");
|
|
9
17
|
|
|
10
18
|
export async function handleAddSecret(req: Request): Promise<Response> {
|
|
11
|
-
const body = await req.json() as {
|
|
19
|
+
const body = (await req.json()) as {
|
|
12
20
|
type?: string;
|
|
13
21
|
name?: string;
|
|
14
22
|
value?: string;
|
|
@@ -16,35 +24,49 @@ export async function handleAddSecret(req: Request): Promise<Response> {
|
|
|
16
24
|
|
|
17
25
|
const { type, name, value } = body;
|
|
18
26
|
|
|
19
|
-
if (!type || typeof type !==
|
|
20
|
-
return httpError(
|
|
27
|
+
if (!type || typeof type !== "string") {
|
|
28
|
+
return httpError("BAD_REQUEST", "type is required", 400);
|
|
21
29
|
}
|
|
22
|
-
if (!name || typeof name !==
|
|
23
|
-
return httpError(
|
|
30
|
+
if (!name || typeof name !== "string") {
|
|
31
|
+
return httpError("BAD_REQUEST", "name is required", 400);
|
|
24
32
|
}
|
|
25
|
-
if (!value || typeof value !==
|
|
26
|
-
return httpError(
|
|
33
|
+
if (!value || typeof value !== "string") {
|
|
34
|
+
return httpError("BAD_REQUEST", "value is required", 400);
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
try {
|
|
30
|
-
if (type ===
|
|
31
|
-
if (
|
|
32
|
-
|
|
38
|
+
if (type === "api_key") {
|
|
39
|
+
if (
|
|
40
|
+
!API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number])
|
|
41
|
+
) {
|
|
42
|
+
return httpError(
|
|
43
|
+
"BAD_REQUEST",
|
|
44
|
+
`Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`,
|
|
45
|
+
400,
|
|
46
|
+
);
|
|
33
47
|
}
|
|
34
48
|
const stored = setSecureKey(name, value);
|
|
35
49
|
if (!stored) {
|
|
36
|
-
return httpError(
|
|
50
|
+
return httpError(
|
|
51
|
+
"INTERNAL_ERROR",
|
|
52
|
+
"Failed to store API key in secure storage",
|
|
53
|
+
500,
|
|
54
|
+
);
|
|
37
55
|
}
|
|
38
56
|
invalidateConfigCache();
|
|
39
57
|
initializeProviders(getConfig());
|
|
40
|
-
log.info({ provider: name },
|
|
58
|
+
log.info({ provider: name }, "API key updated via HTTP");
|
|
41
59
|
return Response.json({ success: true, type, name }, { status: 201 });
|
|
42
60
|
}
|
|
43
61
|
|
|
44
|
-
if (type ===
|
|
45
|
-
const colonIdx = name.indexOf(
|
|
62
|
+
if (type === "credential") {
|
|
63
|
+
const colonIdx = name.indexOf(":");
|
|
46
64
|
if (colonIdx < 1 || colonIdx === name.length - 1) {
|
|
47
|
-
return httpError(
|
|
65
|
+
return httpError(
|
|
66
|
+
"BAD_REQUEST",
|
|
67
|
+
'For credential type, name must be in "service:field" format (e.g. "github:api_token")',
|
|
68
|
+
400,
|
|
69
|
+
);
|
|
48
70
|
}
|
|
49
71
|
assertMetadataWritable();
|
|
50
72
|
const service = name.slice(0, colonIdx);
|
|
@@ -52,72 +74,95 @@ export async function handleAddSecret(req: Request): Promise<Response> {
|
|
|
52
74
|
const key = `credential:${service}:${field}`;
|
|
53
75
|
const stored = setSecureKey(key, value);
|
|
54
76
|
if (!stored) {
|
|
55
|
-
return httpError(
|
|
77
|
+
return httpError(
|
|
78
|
+
"INTERNAL_ERROR",
|
|
79
|
+
"Failed to store credential in secure storage",
|
|
80
|
+
500,
|
|
81
|
+
);
|
|
56
82
|
}
|
|
57
83
|
upsertCredentialMetadata(service, field, {});
|
|
58
|
-
log.info({ service, field },
|
|
84
|
+
log.info({ service, field }, "Credential added via HTTP");
|
|
59
85
|
return Response.json({ success: true, type, name }, { status: 201 });
|
|
60
86
|
}
|
|
61
87
|
|
|
62
|
-
return httpError(
|
|
88
|
+
return httpError(
|
|
89
|
+
"BAD_REQUEST",
|
|
90
|
+
`Unknown secret type: ${type}. Valid types: api_key, credential`,
|
|
91
|
+
400,
|
|
92
|
+
);
|
|
63
93
|
} catch (err) {
|
|
64
94
|
const message = err instanceof Error ? err.message : String(err);
|
|
65
|
-
log.error({ err, type, name },
|
|
66
|
-
return httpError(
|
|
95
|
+
log.error({ err, type, name }, "Failed to add secret via HTTP");
|
|
96
|
+
return httpError("INTERNAL_ERROR", message, 500);
|
|
67
97
|
}
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
export async function handleDeleteSecret(req: Request): Promise<Response> {
|
|
71
|
-
const body = await req.json() as {
|
|
101
|
+
const body = (await req.json()) as {
|
|
72
102
|
type?: string;
|
|
73
103
|
name?: string;
|
|
74
104
|
};
|
|
75
105
|
|
|
76
106
|
const { type, name } = body;
|
|
77
107
|
|
|
78
|
-
if (!type || typeof type !==
|
|
79
|
-
return httpError(
|
|
108
|
+
if (!type || typeof type !== "string") {
|
|
109
|
+
return httpError("BAD_REQUEST", "type is required", 400);
|
|
80
110
|
}
|
|
81
|
-
if (!name || typeof name !==
|
|
82
|
-
return httpError(
|
|
111
|
+
if (!name || typeof name !== "string") {
|
|
112
|
+
return httpError("BAD_REQUEST", "name is required", 400);
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
try {
|
|
86
|
-
if (type ===
|
|
87
|
-
if (
|
|
88
|
-
|
|
116
|
+
if (type === "api_key") {
|
|
117
|
+
if (
|
|
118
|
+
!API_KEY_PROVIDERS.includes(name as (typeof API_KEY_PROVIDERS)[number])
|
|
119
|
+
) {
|
|
120
|
+
return httpError(
|
|
121
|
+
"BAD_REQUEST",
|
|
122
|
+
`Unknown API key provider: ${name}. Valid providers: ${API_KEY_PROVIDERS.join(", ")}`,
|
|
123
|
+
400,
|
|
124
|
+
);
|
|
89
125
|
}
|
|
90
126
|
const deleted = deleteSecureKey(name);
|
|
91
127
|
if (!deleted) {
|
|
92
|
-
return httpError(
|
|
128
|
+
return httpError("NOT_FOUND", `API key not found: ${name}`, 404);
|
|
93
129
|
}
|
|
94
130
|
invalidateConfigCache();
|
|
95
131
|
initializeProviders(getConfig());
|
|
96
|
-
log.info({ provider: name },
|
|
132
|
+
log.info({ provider: name }, "API key deleted via HTTP");
|
|
97
133
|
return Response.json({ success: true, type, name });
|
|
98
134
|
}
|
|
99
135
|
|
|
100
|
-
if (type ===
|
|
101
|
-
const colonIdx = name.indexOf(
|
|
136
|
+
if (type === "credential") {
|
|
137
|
+
const colonIdx = name.indexOf(":");
|
|
102
138
|
if (colonIdx < 1 || colonIdx === name.length - 1) {
|
|
103
|
-
return httpError(
|
|
139
|
+
return httpError(
|
|
140
|
+
"BAD_REQUEST",
|
|
141
|
+
'For credential type, name must be in "service:field" format (e.g. "github:api_token")',
|
|
142
|
+
400,
|
|
143
|
+
);
|
|
104
144
|
}
|
|
105
145
|
const service = name.slice(0, colonIdx);
|
|
106
146
|
const field = name.slice(colonIdx + 1);
|
|
147
|
+
assertMetadataWritable();
|
|
107
148
|
const key = `credential:${service}:${field}`;
|
|
108
149
|
const deleted = deleteSecureKey(key);
|
|
109
150
|
if (!deleted) {
|
|
110
|
-
return httpError(
|
|
151
|
+
return httpError("NOT_FOUND", `Credential not found: ${name}`, 404);
|
|
111
152
|
}
|
|
112
153
|
deleteCredentialMetadata(service, field);
|
|
113
|
-
log.info({ service, field },
|
|
154
|
+
log.info({ service, field }, "Credential deleted via HTTP");
|
|
114
155
|
return Response.json({ success: true, type, name });
|
|
115
156
|
}
|
|
116
157
|
|
|
117
|
-
return httpError(
|
|
158
|
+
return httpError(
|
|
159
|
+
"BAD_REQUEST",
|
|
160
|
+
`Unknown secret type: ${type}. Valid types: api_key, credential`,
|
|
161
|
+
400,
|
|
162
|
+
);
|
|
118
163
|
} catch (err) {
|
|
119
164
|
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
-
log.error({ err, type, name },
|
|
121
|
-
return httpError(
|
|
165
|
+
log.error({ err, type, name }, "Failed to delete secret via HTTP");
|
|
166
|
+
return httpError("INTERNAL_ERROR", message, 500);
|
|
122
167
|
}
|
|
123
168
|
}
|
|
@@ -4,13 +4,23 @@
|
|
|
4
4
|
* POST /v1/surface-actions — dispatch a surface action to an active session.
|
|
5
5
|
* Requires the session to already exist (does not create new sessions).
|
|
6
6
|
*/
|
|
7
|
-
import type { Session } from "../../daemon/session.js";
|
|
8
7
|
import { getLogger } from "../../util/logger.js";
|
|
9
8
|
import { httpError } from "../http-errors.js";
|
|
10
9
|
|
|
11
10
|
const log = getLogger("surface-action-routes");
|
|
12
11
|
|
|
13
|
-
|
|
12
|
+
/** Any object that can handle a surface action (Session or ComputerUseSession). */
|
|
13
|
+
interface SurfaceActionTarget {
|
|
14
|
+
handleSurfaceAction(
|
|
15
|
+
surfaceId: string,
|
|
16
|
+
actionId: string,
|
|
17
|
+
data?: Record<string, unknown>,
|
|
18
|
+
): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SessionLookup = (
|
|
22
|
+
sessionId: string,
|
|
23
|
+
) => SurfaceActionTarget | undefined;
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
26
|
* POST /v1/surface-actions — handle a UI surface action.
|
|
@@ -123,6 +123,19 @@ export async function handleUpdateTrustRuleManage(
|
|
|
123
123
|
priority?: number;
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
+
if (body.decision !== undefined) {
|
|
127
|
+
const validDecisions = ["allow", "deny", "ask"] as const;
|
|
128
|
+
if (
|
|
129
|
+
!validDecisions.includes(body.decision as (typeof validDecisions)[number])
|
|
130
|
+
) {
|
|
131
|
+
return httpError(
|
|
132
|
+
"BAD_REQUEST",
|
|
133
|
+
"decision must be one of: allow, deny, ask",
|
|
134
|
+
400,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
try {
|
|
127
140
|
updateRule(id, {
|
|
128
141
|
tool: body.tool,
|
|
@@ -38,7 +38,7 @@ import { syncTwilioWebhooks } from '../../daemon/handlers/config-ingress.js';
|
|
|
38
38
|
import type { IngressConfig } from '../../inbound/public-ingress-urls.js';
|
|
39
39
|
import { deleteSecureKey, getSecureKey, setSecureKey } from '../../security/secure-keys.js';
|
|
40
40
|
import { deleteCredentialMetadata, upsertCredentialMetadata } from '../../tools/credentials/metadata-store.js';
|
|
41
|
-
import {
|
|
41
|
+
import { mintDaemonDeliveryToken } from '../auth/token-service.js';
|
|
42
42
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
// Shared helpers
|
|
@@ -716,14 +716,14 @@ export async function handleSmsSendTest(req: Request): Promise<Response> {
|
|
|
716
716
|
const text = body.text || 'Test SMS from your Vellum assistant';
|
|
717
717
|
|
|
718
718
|
// Send via gateway's /deliver/sms endpoint
|
|
719
|
-
const bearerToken =
|
|
719
|
+
const bearerToken = mintDaemonDeliveryToken();
|
|
720
720
|
const gatewayUrl = getGatewayInternalBaseUrl();
|
|
721
721
|
|
|
722
722
|
const sendResp = await fetch(`${gatewayUrl}/deliver/sms`, {
|
|
723
723
|
method: 'POST',
|
|
724
724
|
headers: {
|
|
725
725
|
'Content-Type': 'application/json',
|
|
726
|
-
|
|
726
|
+
Authorization: `Bearer ${bearerToken}`,
|
|
727
727
|
},
|
|
728
728
|
body: JSON.stringify({ to, text }),
|
|
729
729
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-conversation temporary approval overrides.
|
|
3
|
+
*
|
|
4
|
+
* When a user chooses `allow_thread` or `allow_10m`, the session records a
|
|
5
|
+
* temporary approval mode scoped to the conversation. Subsequent tool-use
|
|
6
|
+
* confirmations within the same conversation can check this state to
|
|
7
|
+
* auto-approve without prompting.
|
|
8
|
+
*
|
|
9
|
+
* State is in-memory only -- it does not survive daemon restarts, which is
|
|
10
|
+
* the desired behavior for temporary approvals. Thread-scoped overrides
|
|
11
|
+
* persist until the session ends or the mode is explicitly cleared. Timed
|
|
12
|
+
* overrides expire after their TTL (checked at read time, no background sweep).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type TemporaryApprovalMode =
|
|
16
|
+
| { kind: "thread" }
|
|
17
|
+
| { kind: "timed"; expiresAt: number };
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMED_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
|
20
|
+
|
|
21
|
+
const store = new Map<string, TemporaryApprovalMode>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set thread-scoped temporary approval for a conversation.
|
|
25
|
+
* Remains active until explicitly cleared or session ends.
|
|
26
|
+
* Replaces any existing mode for the conversation.
|
|
27
|
+
*/
|
|
28
|
+
export function setThreadMode(conversationId: string): void {
|
|
29
|
+
store.set(conversationId, { kind: "thread" });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Set time-limited temporary approval for a conversation.
|
|
34
|
+
* Replaces any existing mode for the conversation.
|
|
35
|
+
*
|
|
36
|
+
* @param conversationId - The conversation to scope the override to
|
|
37
|
+
* @param durationMs - How long the override lasts (defaults to 10 minutes)
|
|
38
|
+
*/
|
|
39
|
+
export function setTimedMode(
|
|
40
|
+
conversationId: string,
|
|
41
|
+
durationMs: number = DEFAULT_TIMED_DURATION_MS,
|
|
42
|
+
): void {
|
|
43
|
+
store.set(conversationId, {
|
|
44
|
+
kind: "timed",
|
|
45
|
+
expiresAt: Date.now() + durationMs,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Clear any temporary approval mode for a conversation.
|
|
51
|
+
*/
|
|
52
|
+
export function clearMode(conversationId: string): void {
|
|
53
|
+
store.delete(conversationId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the effective temporary approval mode for a conversation.
|
|
58
|
+
*
|
|
59
|
+
* Returns undefined if no mode is set or if a timed mode has expired.
|
|
60
|
+
* Expired timed modes are cleaned up lazily on read.
|
|
61
|
+
*/
|
|
62
|
+
export function getEffectiveMode(
|
|
63
|
+
conversationId: string,
|
|
64
|
+
): TemporaryApprovalMode | undefined {
|
|
65
|
+
const mode = store.get(conversationId);
|
|
66
|
+
if (!mode) return undefined;
|
|
67
|
+
|
|
68
|
+
if (mode.kind === "timed" && Date.now() >= mode.expiresAt) {
|
|
69
|
+
store.delete(conversationId);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return mode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check whether a conversation has an active (non-expired) temporary approval.
|
|
78
|
+
*/
|
|
79
|
+
export function hasActiveOverride(conversationId: string): boolean {
|
|
80
|
+
return getEffectiveMode(conversationId) !== undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Clear all overrides. Useful for testing. */
|
|
84
|
+
export function clearAll(): void {
|
|
85
|
+
store.clear();
|
|
86
|
+
}
|
|
@@ -35,6 +35,7 @@ export function migrateKeychainToEncrypted(): void {
|
|
|
35
35
|
if (encryptedStore.getKey(MIGRATION_MARKER) === "true") return;
|
|
36
36
|
|
|
37
37
|
let migrated = 0;
|
|
38
|
+
let hadErrors = false;
|
|
38
39
|
const allKeys = [...API_KEY_PROVIDERS, ...CREDENTIAL_KEYS];
|
|
39
40
|
|
|
40
41
|
for (const account of allKeys) {
|
|
@@ -45,10 +46,16 @@ export function migrateKeychainToEncrypted(): void {
|
|
|
45
46
|
migrated++;
|
|
46
47
|
}
|
|
47
48
|
} catch {
|
|
48
|
-
|
|
49
|
+
hadErrors = true;
|
|
50
|
+
log.warn({ account }, "Keychain read failed during migration");
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
if (hadErrors) {
|
|
55
|
+
log.warn("Skipping migration marker — will retry on next startup");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
52
59
|
encryptedStore.setKey(MIGRATION_MARKER, "true");
|
|
53
60
|
if (migrated > 0) {
|
|
54
61
|
log.info(
|
|
@@ -34,15 +34,55 @@ export function parseFrontmatterFields(
|
|
|
34
34
|
const frontmatter = match[1];
|
|
35
35
|
const fields: Record<string, string> = {};
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
const lines = frontmatter.split(/\r?\n/);
|
|
38
|
+
let currentKey: string | undefined;
|
|
39
|
+
let continuationLines: string[] = [];
|
|
40
|
+
|
|
41
|
+
function flushContinuation() {
|
|
42
|
+
if (currentKey !== undefined) {
|
|
43
|
+
if (continuationLines.length > 0) {
|
|
44
|
+
// Join continuation lines, then strip trailing commas before closing
|
|
45
|
+
// braces/brackets so that prettier-formatted JSON remains valid for JSON.parse.
|
|
46
|
+
fields[currentKey] = continuationLines
|
|
47
|
+
.map((l) => l.trim())
|
|
48
|
+
.join(" ")
|
|
49
|
+
.replace(/,\s*([}\]])/g, "$1");
|
|
50
|
+
} else {
|
|
51
|
+
fields[currentKey] = "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
currentKey = undefined;
|
|
55
|
+
continuationLines = [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for (const line of lines) {
|
|
38
59
|
const trimmed = line.trim();
|
|
39
60
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
61
|
+
|
|
62
|
+
// Continuation line: indented and no top-level key: pattern
|
|
63
|
+
// (i.e. starts with whitespace and either has no colon or the colon
|
|
64
|
+
// is inside braces/quotes — heuristic: line starts with space/tab)
|
|
65
|
+
if (currentKey !== undefined && /^\s/.test(line)) {
|
|
66
|
+
continuationLines.push(trimmed);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Flush any pending multiline value
|
|
71
|
+
flushContinuation();
|
|
72
|
+
|
|
40
73
|
const separatorIndex = trimmed.indexOf(":");
|
|
41
74
|
if (separatorIndex === -1) continue;
|
|
42
75
|
|
|
43
76
|
const key = trimmed.slice(0, separatorIndex).trim();
|
|
44
77
|
let value = trimmed.slice(separatorIndex + 1).trim();
|
|
45
78
|
|
|
79
|
+
if (!value) {
|
|
80
|
+
// Value may continue on subsequent indented lines
|
|
81
|
+
currentKey = key;
|
|
82
|
+
continuationLines = [];
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
46
86
|
const isDoubleQuoted = value.startsWith('"') && value.endsWith('"');
|
|
47
87
|
const isSingleQuoted = value.startsWith("'") && value.endsWith("'");
|
|
48
88
|
if (isDoubleQuoted || isSingleQuoted) {
|
|
@@ -61,5 +101,8 @@ export function parseFrontmatterFields(
|
|
|
61
101
|
fields[key] = value;
|
|
62
102
|
}
|
|
63
103
|
|
|
104
|
+
// Flush any trailing multiline value
|
|
105
|
+
flushContinuation();
|
|
106
|
+
|
|
64
107
|
return { fields, body: content.slice(match[0].length) };
|
|
65
108
|
}
|