@pellux/goodvibes-daemon 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-ws-call.ts — invoking a verb that has no REST binding.
|
|
3
|
+
*
|
|
4
|
+
* Most control-plane verbs answer on a plain HTTP path and `callDaemonVerb` in
|
|
5
|
+
* @pellux/goodvibes-terminal-shell is all a subcommand needs. Some do not: the
|
|
6
|
+
* `sessions.hosted.*` family is declared ws-only in the method catalog (no
|
|
7
|
+
* `http` binding at all), because a hosted session's whole point is the event
|
|
8
|
+
* stream that comes with it. A GET against a path they do not have returns 404,
|
|
9
|
+
* which reads exactly like an out-of-date daemon.
|
|
10
|
+
*
|
|
11
|
+
* So this is the second half of the SAME convention: the target is resolved by
|
|
12
|
+
* `resolveRemoteDaemonTarget` (the --host/--port/--token flags, defaulting to
|
|
13
|
+
* this machine), the credential is the same operator token, and only the
|
|
14
|
+
* transport differs. Nothing here knows what any verb MEANS.
|
|
15
|
+
*
|
|
16
|
+
* The frames are the ones the operator contract declares for
|
|
17
|
+
* `/api/control-plane/ws`:
|
|
18
|
+
* -> {"type":"auth","token":"…"} <- {"type":"auth","ok":true,…}
|
|
19
|
+
* -> {"type":"call","id":"…","methodId":"…","body":{…}}
|
|
20
|
+
* <- {"type":"response","id":"…","ok":…,"status":…,"body":…}
|
|
21
|
+
*
|
|
22
|
+
* The Authorization header is sent on the upgrade as well as in the auth frame.
|
|
23
|
+
* The daemon requires it on the upgrade (an unauthenticated upgrade is a 401
|
|
24
|
+
* before any frame is read) and re-reads it from the frame; sending both is
|
|
25
|
+
* what makes one connection work for both checks.
|
|
26
|
+
*/
|
|
27
|
+
import type { DaemonVerbOutcome, RemoteDaemonTarget } from '@pellux/goodvibes-terminal-shell';
|
|
28
|
+
|
|
29
|
+
/** How long a single verb call may take, upgrade included. */
|
|
30
|
+
export const DAEMON_WS_TIMEOUT_MS = 15_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The socket shape this module uses.
|
|
34
|
+
*
|
|
35
|
+
* Narrower than the platform `WebSocket` on purpose: a test double has no
|
|
36
|
+
* business implementing `binaryType`, `extensions` or the EventTarget surface,
|
|
37
|
+
* and requiring them would push every test into a cast.
|
|
38
|
+
*/
|
|
39
|
+
export interface DaemonWebSocket {
|
|
40
|
+
send(data: string): void;
|
|
41
|
+
close(): void;
|
|
42
|
+
onopen: ((event: unknown) => void) | null;
|
|
43
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
44
|
+
onerror: ((event: unknown) => void) | null;
|
|
45
|
+
onclose: ((event: unknown) => void) | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type DaemonWebSocketFactory = (
|
|
49
|
+
url: string,
|
|
50
|
+
init: { readonly headers: Readonly<Record<string, string>> },
|
|
51
|
+
) => DaemonWebSocket;
|
|
52
|
+
|
|
53
|
+
/** The real one. Bun's WebSocket takes headers on the constructor; browsers' does not. */
|
|
54
|
+
const realWebSocketFactory: DaemonWebSocketFactory = (url, init) =>
|
|
55
|
+
new WebSocket(url, init as unknown as string[]) as unknown as DaemonWebSocket;
|
|
56
|
+
|
|
57
|
+
export interface CallDaemonWsVerbOptions {
|
|
58
|
+
readonly body?: unknown;
|
|
59
|
+
readonly timeoutMs?: number | undefined;
|
|
60
|
+
/** Injected in tests so nothing opens a socket. */
|
|
61
|
+
readonly socketFactory?: DaemonWebSocketFactory | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function wsUrlFor(baseUrl: string): string {
|
|
65
|
+
return `${baseUrl.replace(/^http/, 'ws')}/api/control-plane/ws`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ParsedFrame {
|
|
69
|
+
readonly type?: unknown;
|
|
70
|
+
readonly ok?: unknown;
|
|
71
|
+
readonly id?: unknown;
|
|
72
|
+
readonly status?: unknown;
|
|
73
|
+
readonly body?: unknown;
|
|
74
|
+
readonly error?: unknown;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseFrame(data: unknown): ParsedFrame | null {
|
|
78
|
+
const text = typeof data === 'string'
|
|
79
|
+
? data
|
|
80
|
+
: data instanceof Uint8Array
|
|
81
|
+
? new TextDecoder().decode(data)
|
|
82
|
+
: null;
|
|
83
|
+
if (text === null) return null;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(text) as unknown;
|
|
86
|
+
return parsed && typeof parsed === 'object' ? (parsed as ParsedFrame) : null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Call one ws-only verb and close.
|
|
94
|
+
*
|
|
95
|
+
* One connection per call. A CLI command asks one question and exits, so a
|
|
96
|
+
* pooled connection would only be a lifetime to get wrong; the cost is one
|
|
97
|
+
* upgrade against a daemon that is, in the default case, on this machine.
|
|
98
|
+
*
|
|
99
|
+
* Every failure shape becomes an `error`/`fix` pair in the same vocabulary
|
|
100
|
+
* `callDaemonVerb` produces, so a caller renders both the same way.
|
|
101
|
+
*/
|
|
102
|
+
export async function callDaemonWsVerb<T>(
|
|
103
|
+
target: RemoteDaemonTarget,
|
|
104
|
+
methodId: string,
|
|
105
|
+
options: CallDaemonWsVerbOptions = {},
|
|
106
|
+
): Promise<DaemonVerbOutcome<T>> {
|
|
107
|
+
const where = target.isLocal ? 'the daemon on this machine' : `the daemon at ${target.baseUrl}`;
|
|
108
|
+
const factory = options.socketFactory ?? realWebSocketFactory;
|
|
109
|
+
const timeoutMs = options.timeoutMs ?? DAEMON_WS_TIMEOUT_MS;
|
|
110
|
+
const callId = `cli-${methodId}-${Date.now()}`;
|
|
111
|
+
|
|
112
|
+
let socket: DaemonWebSocket;
|
|
113
|
+
try {
|
|
114
|
+
socket = factory(wsUrlFor(target.baseUrl), {
|
|
115
|
+
headers: { Authorization: `Bearer ${target.token}` },
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
error: `could not reach ${where}`,
|
|
121
|
+
fix: target.isLocal
|
|
122
|
+
? 'check the daemon is running: goodvibes-daemon service-status'
|
|
123
|
+
: `check that machine is reachable and its daemon is listening on ${target.baseUrl} `
|
|
124
|
+
+ `(${error instanceof Error ? error.message : 'no further detail'})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return new Promise<DaemonVerbOutcome<T>>((resolve) => {
|
|
129
|
+
let settled = false;
|
|
130
|
+
const timer = setTimeout(() => {
|
|
131
|
+
finish({
|
|
132
|
+
ok: false,
|
|
133
|
+
error: `${where} did not answer within ${Math.round(timeoutMs / 1000)}s`,
|
|
134
|
+
fix: target.isLocal
|
|
135
|
+
? 'check the daemon is running and not wedged: goodvibes-daemon status'
|
|
136
|
+
: 'check that machine is reachable and its daemon is not overloaded',
|
|
137
|
+
});
|
|
138
|
+
}, timeoutMs);
|
|
139
|
+
|
|
140
|
+
const finish = (outcome: DaemonVerbOutcome<T>): void => {
|
|
141
|
+
if (settled) return;
|
|
142
|
+
settled = true;
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
try {
|
|
145
|
+
socket.close();
|
|
146
|
+
} catch {
|
|
147
|
+
// A socket that is already gone is exactly the state we wanted.
|
|
148
|
+
}
|
|
149
|
+
resolve(outcome);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
socket.onopen = (): void => {
|
|
153
|
+
socket.send(JSON.stringify({ type: 'auth', token: target.token }));
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
socket.onmessage = (event): void => {
|
|
157
|
+
const frame = parseFrame(event.data);
|
|
158
|
+
if (!frame) return;
|
|
159
|
+
|
|
160
|
+
if (frame.type === 'auth') {
|
|
161
|
+
if (frame.ok === true) {
|
|
162
|
+
socket.send(JSON.stringify({
|
|
163
|
+
type: 'call',
|
|
164
|
+
id: callId,
|
|
165
|
+
methodId,
|
|
166
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
167
|
+
}));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
finish({
|
|
171
|
+
ok: false,
|
|
172
|
+
error: `${where} refused the operator token`,
|
|
173
|
+
fix: target.isLocal
|
|
174
|
+
? 'the token may be stale — restart the daemon, or pass --token'
|
|
175
|
+
: 'pass --token with the operator token from that machine (its <daemon home>/operator-tokens.json)',
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (frame.type === 'error') {
|
|
181
|
+
finish({
|
|
182
|
+
ok: false,
|
|
183
|
+
error: typeof frame.error === 'string' ? frame.error : `${where} refused the request`,
|
|
184
|
+
fix: 'run `goodvibes-daemon status` to see what that daemon is doing',
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (frame.type !== 'response' || frame.id !== callId) return;
|
|
190
|
+
|
|
191
|
+
if (frame.ok === true) {
|
|
192
|
+
finish({ ok: true, data: frame.body as T });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const body = (frame.body ?? {}) as { error?: unknown; fix?: unknown };
|
|
196
|
+
if (frame.status === 404) {
|
|
197
|
+
finish({
|
|
198
|
+
ok: false,
|
|
199
|
+
error: `${where} does not know the verb ${methodId}`,
|
|
200
|
+
fix: 'that daemon is running a build without this capability — update it, then try again',
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
finish({
|
|
205
|
+
ok: false,
|
|
206
|
+
error: typeof body.error === 'string' ? body.error : `${where} refused ${methodId}`,
|
|
207
|
+
fix: typeof body.fix === 'string'
|
|
208
|
+
? body.fix
|
|
209
|
+
: 'run `goodvibes-daemon status` to see what that daemon is doing',
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
socket.onerror = (): void => {
|
|
214
|
+
finish({
|
|
215
|
+
ok: false,
|
|
216
|
+
error: `could not reach ${where}`,
|
|
217
|
+
fix: target.isLocal
|
|
218
|
+
? 'check the daemon is running: goodvibes-daemon service-status'
|
|
219
|
+
: `check that machine is switched on and its daemon is listening on ${target.baseUrl}`,
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
socket.onclose = (): void => {
|
|
224
|
+
// A close before an answer is a refusal too — most often the upgrade
|
|
225
|
+
// itself was rejected, which happens before any frame is sent.
|
|
226
|
+
finish({
|
|
227
|
+
ok: false,
|
|
228
|
+
error: `${where} closed the connection before answering`,
|
|
229
|
+
fix: target.isLocal
|
|
230
|
+
? 'check the daemon is running: goodvibes-daemon service-status'
|
|
231
|
+
: 'check the operator token for that machine — an upgrade with no valid token is closed immediately',
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* raw-reply-route.ts — invoking a route that answers with its payload itself.
|
|
3
|
+
*
|
|
4
|
+
* `callDaemonVerb` in @pellux/goodvibes-terminal-shell reads the wrapped
|
|
5
|
+
* convention every `/api/cluster/*` route follows: `{ ok: true, data }` on
|
|
6
|
+
* success, `{ ok: false, error, fix }` on a refusal. Three routes this daemon
|
|
7
|
+
* serves do not — `/status`, `/api/health` and `/api/channels/status` answer
|
|
8
|
+
* with the payload ITSELF and put the verdict in the HTTP status. Reading one
|
|
9
|
+
* as the other is not a subtle failure: a raw payload has no `ok` field, so the
|
|
10
|
+
* wrapped reader called a perfectly healthy 200 "the daemon refused the
|
|
11
|
+
* request".
|
|
12
|
+
*
|
|
13
|
+
* So this is the second half of the SAME convention, next to daemon-ws-call.ts:
|
|
14
|
+
* the target is resolved by `resolveRemoteDaemonTarget`, the credential is the
|
|
15
|
+
* same operator token, the reachability / stale-credential / unreadable-reply
|
|
16
|
+
* refusals are the shared reader's — only the shape of a successful body
|
|
17
|
+
* differs. `rawReplyReader` restates a raw reply in the wrapped convention
|
|
18
|
+
* before the shared reader sees it, so there is one request path rather than
|
|
19
|
+
* two.
|
|
20
|
+
*
|
|
21
|
+
* Which routes are raw is stated per call, never sniffed: a payload is free to
|
|
22
|
+
* contain a field called `ok` and no sniffing rule could be honest about that.
|
|
23
|
+
*/
|
|
24
|
+
import { callDaemonVerb, type DaemonFetch, type DaemonVerbOutcome, type RemoteDaemonTarget } from '@pellux/goodvibes-terminal-shell';
|
|
25
|
+
|
|
26
|
+
/** How a route's reply is shaped. */
|
|
27
|
+
export type DaemonReplyEnvelope = 'wrapped' | 'raw';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The status a restated reply carries.
|
|
31
|
+
*
|
|
32
|
+
* The shared reader looks at the status for exactly three verdicts — 401, 403
|
|
33
|
+
* and 404 — and those are passed through untouched below, before any body is
|
|
34
|
+
* read. Everything else it decides from the body, so a restated reply names a
|
|
35
|
+
* status that is legal to attach a body to (a 204 or a 304 is not) rather than
|
|
36
|
+
* echoing one that would make `new Response` throw.
|
|
37
|
+
*/
|
|
38
|
+
const RESTATED_OK = 200;
|
|
39
|
+
const RESTATED_REFUSAL = 500;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wrap a fetch so a raw-answering route reads as a wrapped one.
|
|
43
|
+
*
|
|
44
|
+
* A reply the shared reader short-circuits on (a credential refusal, an
|
|
45
|
+
* unknown path) is handed back exactly as it arrived. A body that is not JSON
|
|
46
|
+
* is handed back unparsed, so the shared reader produces its own
|
|
47
|
+
* "reply this build could not read" refusal rather than a second wording for
|
|
48
|
+
* the same thing.
|
|
49
|
+
*/
|
|
50
|
+
export function rawReplyReader(target: RemoteDaemonTarget, fetchImpl: DaemonFetch = fetch): DaemonFetch {
|
|
51
|
+
const where = target.isLocal ? 'the daemon on this machine' : `the daemon at ${target.baseUrl}`;
|
|
52
|
+
return async (input, init) => {
|
|
53
|
+
const response = await fetchImpl(input, init);
|
|
54
|
+
if (response.status === 401 || response.status === 403 || response.status === 404) return response;
|
|
55
|
+
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
let payload: unknown;
|
|
58
|
+
try {
|
|
59
|
+
payload = JSON.parse(text) as unknown;
|
|
60
|
+
} catch {
|
|
61
|
+
return new Response(text, { status: RESTATED_OK });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (response.ok) {
|
|
65
|
+
return new Response(JSON.stringify({ ok: true, data: payload }), { status: RESTATED_OK });
|
|
66
|
+
}
|
|
67
|
+
const body = payload as { error?: unknown; fix?: unknown };
|
|
68
|
+
return new Response(JSON.stringify({
|
|
69
|
+
ok: false,
|
|
70
|
+
error: typeof body.error === 'string'
|
|
71
|
+
? body.error
|
|
72
|
+
: `${where} answered ${response.status} for ${pathOf(input)}`,
|
|
73
|
+
fix: typeof body.fix === 'string'
|
|
74
|
+
? body.fix
|
|
75
|
+
: 'run `goodvibes-daemon status` to see what that daemon is doing',
|
|
76
|
+
}), { status: RESTATED_REFUSAL });
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The path portion of a request URL, for a refusal that names what was asked for. */
|
|
81
|
+
function pathOf(url: string): string {
|
|
82
|
+
try {
|
|
83
|
+
return new URL(url).pathname;
|
|
84
|
+
} catch {
|
|
85
|
+
return url;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Call a daemon route, saying which envelope it answers in.
|
|
91
|
+
*
|
|
92
|
+
* `wrapped` is the shared reader unchanged. `raw` is the shared reader with the
|
|
93
|
+
* restating fetch above in front of it, so both envelopes reach the same
|
|
94
|
+
* refusals in the same words.
|
|
95
|
+
*/
|
|
96
|
+
export function callDaemonRoute<T>(
|
|
97
|
+
target: RemoteDaemonTarget,
|
|
98
|
+
path: string,
|
|
99
|
+
init: { method: 'GET' | 'POST'; body?: unknown; envelope?: DaemonReplyEnvelope } = { method: 'GET' },
|
|
100
|
+
fetchImpl: DaemonFetch = fetch,
|
|
101
|
+
): Promise<DaemonVerbOutcome<T>> {
|
|
102
|
+
const request = init.body === undefined
|
|
103
|
+
? { method: init.method }
|
|
104
|
+
: { method: init.method, body: init.body };
|
|
105
|
+
return callDaemonVerb<T>(
|
|
106
|
+
target,
|
|
107
|
+
path,
|
|
108
|
+
request,
|
|
109
|
+
(init.envelope ?? 'wrapped') === 'raw' ? rawReplyReader(target, fetchImpl) : fetchImpl,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checkpoint guard settings (`checkpoints.*` namespace).
|
|
5
|
+
*
|
|
6
|
+
* The SDK's WorkspaceCheckpointManager exposes root/retention guard options that
|
|
7
|
+
* decide which directory it is safe to snapshot and how the first sweep and
|
|
8
|
+
* retention behave:
|
|
9
|
+
*
|
|
10
|
+
* - `checkpoints.preferGitRoot` (boolean, SDK default true) — prefer the
|
|
11
|
+
* enclosing git repository's top level over the raw working directory.
|
|
12
|
+
* - `checkpoints.allowBroadRoot` (boolean, SDK default false) — opt in to
|
|
13
|
+
* snapshotting a broad root (filesystem root, home directory, ~/.goodvibes).
|
|
14
|
+
* - `checkpoints.allowLargeFirstSnapshot` (boolean, SDK default false) — opt
|
|
15
|
+
* in to a first snapshot whose full sweep exceeds `maxFirstSnapshotFiles`.
|
|
16
|
+
* - `checkpoints.maxFirstSnapshotFiles` (number, SDK default) — ceiling for
|
|
17
|
+
* the first-ever snapshot's file sweep.
|
|
18
|
+
* - `checkpoints.autoRetention` (boolean, SDK default true) — run a retention
|
|
19
|
+
* sweep automatically after each successful create and once at init.
|
|
20
|
+
*
|
|
21
|
+
* A sixth key, `checkpoints.unregisteredWorkspaces`, is read separately by
|
|
22
|
+
* `readCheckpointRegistrationSetting` below (kept out of `CheckpointGuardSettings`
|
|
23
|
+
* because it is a daemon-owned enforcement switch, not an SDK manager
|
|
24
|
+
* constructor option, and must never be spread into the manager's options).
|
|
25
|
+
*
|
|
26
|
+
* The shared SDK config schema (GoodVibesConfig) has no `checkpoints` category,
|
|
27
|
+
* so these are read directly from a user-supplied `checkpoints` block in
|
|
28
|
+
* settings.json. The SDK ConfigManager deep-merges loaded settings onto the
|
|
29
|
+
* default config and preserves unknown top-level blocks, so a hand-added
|
|
30
|
+
* `checkpoints` object survives to `getRaw()`. Only these five passthrough keys
|
|
31
|
+
* are read here; each absent key falls back to the SDK manager's own default.
|
|
32
|
+
*
|
|
33
|
+
* settings.json example:
|
|
34
|
+
*
|
|
35
|
+
* "checkpoints": {
|
|
36
|
+
* "preferGitRoot": true,
|
|
37
|
+
* "allowBroadRoot": false,
|
|
38
|
+
* "allowLargeFirstSnapshot": false,
|
|
39
|
+
* "maxFirstSnapshotFiles": 20000,
|
|
40
|
+
* "autoRetention": true,
|
|
41
|
+
* "unregisteredWorkspaces": "off"
|
|
42
|
+
* }
|
|
43
|
+
*/
|
|
44
|
+
export interface CheckpointGuardSettings {
|
|
45
|
+
readonly preferGitRoot?: boolean;
|
|
46
|
+
readonly allowBroadRoot?: boolean;
|
|
47
|
+
readonly allowLargeFirstSnapshot?: boolean;
|
|
48
|
+
readonly maxFirstSnapshotFiles?: number;
|
|
49
|
+
readonly autoRetention?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the `checkpoints.*` guard passthrough keys from the loaded config. Only
|
|
54
|
+
* keys present with the right primitive type are returned; everything else is
|
|
55
|
+
* omitted so the SDK manager applies its own default. `maxFirstSnapshotFiles`
|
|
56
|
+
* must be a finite positive number to be accepted.
|
|
57
|
+
*/
|
|
58
|
+
export function readCheckpointGuardSettings(
|
|
59
|
+
configManager: Pick<ConfigManager, 'getRaw'>,
|
|
60
|
+
): CheckpointGuardSettings {
|
|
61
|
+
const raw = configManager.getRaw() as unknown as Record<string, unknown>;
|
|
62
|
+
const block = raw.checkpoints;
|
|
63
|
+
if (block === null || typeof block !== 'object' || Array.isArray(block)) return {};
|
|
64
|
+
const cp = block as Record<string, unknown>;
|
|
65
|
+
|
|
66
|
+
const out: {
|
|
67
|
+
preferGitRoot?: boolean;
|
|
68
|
+
allowBroadRoot?: boolean;
|
|
69
|
+
allowLargeFirstSnapshot?: boolean;
|
|
70
|
+
maxFirstSnapshotFiles?: number;
|
|
71
|
+
autoRetention?: boolean;
|
|
72
|
+
} = {};
|
|
73
|
+
|
|
74
|
+
if (typeof cp.preferGitRoot === 'boolean') out.preferGitRoot = cp.preferGitRoot;
|
|
75
|
+
if (typeof cp.allowBroadRoot === 'boolean') out.allowBroadRoot = cp.allowBroadRoot;
|
|
76
|
+
if (typeof cp.allowLargeFirstSnapshot === 'boolean') out.allowLargeFirstSnapshot = cp.allowLargeFirstSnapshot;
|
|
77
|
+
if (
|
|
78
|
+
typeof cp.maxFirstSnapshotFiles === 'number'
|
|
79
|
+
&& Number.isFinite(cp.maxFirstSnapshotFiles)
|
|
80
|
+
&& cp.maxFirstSnapshotFiles > 0
|
|
81
|
+
) {
|
|
82
|
+
out.maxFirstSnapshotFiles = cp.maxFirstSnapshotFiles;
|
|
83
|
+
}
|
|
84
|
+
if (typeof cp.autoRetention === 'boolean') out.autoRetention = cp.autoRetention;
|
|
85
|
+
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The registered-workspaces-only override (owner ruling, 2026-07-10):
|
|
91
|
+
*
|
|
92
|
+
* - `checkpoints.unregisteredWorkspaces` (`'off' | 'guarded'`, default `'off'`)
|
|
93
|
+
* — `'off'` is the ruling's default: automatic (turn-end/lifecycle)
|
|
94
|
+
* checkpoints, and explicit checkpoint creation through the ws-only
|
|
95
|
+
* `checkpoints.create` gateway verb, both refuse when the resolved
|
|
96
|
+
* workspace root is not covered by the shared registration store
|
|
97
|
+
* (the SDK's workspace registration, platform/workspace).
|
|
98
|
+
* `'guarded'` opts back into the pre-ruling behavior for an unregistered
|
|
99
|
+
* workspace: automatic snapshots subscribe and explicit create proceeds,
|
|
100
|
+
* subject only to the SDK's own root/size guards above — never a silent
|
|
101
|
+
* re-enable, an explicit per-workspace opt-out of the registration gate.
|
|
102
|
+
*
|
|
103
|
+
* Any other value (including absence) reads as `'off'`.
|
|
104
|
+
*/
|
|
105
|
+
export function readCheckpointRegistrationSetting(
|
|
106
|
+
configManager: Pick<ConfigManager, 'getRaw'>,
|
|
107
|
+
): 'off' | 'guarded' {
|
|
108
|
+
const raw = configManager.getRaw() as unknown as Record<string, unknown>;
|
|
109
|
+
const block = raw.checkpoints;
|
|
110
|
+
if (block === null || typeof block !== 'object' || Array.isArray(block)) return 'off';
|
|
111
|
+
const value = (block as Record<string, unknown>).unregisteredWorkspaces;
|
|
112
|
+
return value === 'guarded' ? 'guarded' : 'off';
|
|
113
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run-daemon-config-migration.ts — the one call every composition root in
|
|
3
|
+
* this daemon makes before constructing its `ConfigManager`.
|
|
4
|
+
*
|
|
5
|
+
* Daemon-owned configuration now has exactly one home:
|
|
6
|
+
* `~/.goodvibes/daemon/settings.json`. Before this migration, every product
|
|
7
|
+
* wrote every key (including daemon-only ones like `surfaces.telegram.*`)
|
|
8
|
+
* into its own per-surface silo, and the daemon only ever read
|
|
9
|
+
* `~/.goodvibes/tui/settings.json` — so a value written by, say, the agent
|
|
10
|
+
* surface reported a successful save and configured nothing the daemon could
|
|
11
|
+
* see.
|
|
12
|
+
*
|
|
13
|
+
* `migrateDaemonOwnedConfig` (SDK, `platform/config`) is idempotent and cheap
|
|
14
|
+
* on the fast path (one file read + JSON parse), so it is safe and correct to
|
|
15
|
+
* call this at every composition root that is about to construct a
|
|
16
|
+
* `ConfigManager` — not just the first one to run in a given process. It
|
|
17
|
+
* must never abort startup: any failure is caught, logged with the marker
|
|
18
|
+
* path so the failure is diagnosable, and startup continues on whatever
|
|
19
|
+
* config state already exists.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
migrateDaemonOwnedConfig,
|
|
23
|
+
daemonConfigPath,
|
|
24
|
+
daemonConfigMovedPath,
|
|
25
|
+
type DaemonConfigMigrationResult,
|
|
26
|
+
} from '@pellux/goodvibes-sdk/platform/config';
|
|
27
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
28
|
+
import { GOODVIBES_DAEMON_SURFACE_ROOT } from './surface.ts';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run the daemon-owned-config migration for `homeDir`, tolerating any
|
|
32
|
+
* failure. Returns the migration result on success, or `null` when the
|
|
33
|
+
* migration itself threw (logged as a warning naming the marker path;
|
|
34
|
+
* startup proceeds either way).
|
|
35
|
+
*/
|
|
36
|
+
export function runDaemonConfigMigration(homeDir: string): DaemonConfigMigrationResult | null {
|
|
37
|
+
try {
|
|
38
|
+
return migrateDaemonOwnedConfig({ homeDir, primarySurface: GOODVIBES_DAEMON_SURFACE_ROOT });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const markerPath = daemonConfigMovedPath(daemonConfigPath(homeDir));
|
|
41
|
+
logger.warn('daemon-owned config migration failed; continuing with existing config state', {
|
|
42
|
+
markerPath,
|
|
43
|
+
error: summarizeError(error),
|
|
44
|
+
});
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { isSecretRefInput, isDaemonOwnedConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
|
+
import type { SecretScope, SecretStorageMedium } from './secrets.ts';
|
|
4
|
+
|
|
5
|
+
export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
|
|
6
|
+
// Mailbox and CalDAV credentials. Their own CONFIG_SCHEMA descriptions read
|
|
7
|
+
// "Stored in the daemon secret tier, never in config" — and until they were
|
|
8
|
+
// listed here that sentence was aspirational: the settings modal wrote them
|
|
9
|
+
// as plain strings into a config JSON file, because membership in this set is
|
|
10
|
+
// the thing that routes an edit through the secret manager instead.
|
|
11
|
+
//
|
|
12
|
+
// The daemon reads each of these back with
|
|
13
|
+
// resolveConfigSecret('<key>') → GOODVIBES_<KEY>, which is exactly the store
|
|
14
|
+
// key buildGoodVibesSecretKey() writes — see
|
|
15
|
+
// daemon/handlers/inbox/providers/email.ts.
|
|
16
|
+
'surfaces.email.password',
|
|
17
|
+
'surfaces.email.imapPassword',
|
|
18
|
+
'surfaces.email.imap.password',
|
|
19
|
+
'surfaces.email.smtp.password',
|
|
20
|
+
'surfaces.calendar.caldavPassword',
|
|
21
|
+
// Telephony delivery credentials — same shape, same file, same gap.
|
|
22
|
+
'surfaces.telephony.authToken',
|
|
23
|
+
'surfaces.telephony.token',
|
|
24
|
+
'surfaces.telephony.webhookSecret',
|
|
25
|
+
'surfaces.slack.signingSecret',
|
|
26
|
+
'surfaces.slack.botToken',
|
|
27
|
+
'surfaces.slack.appToken',
|
|
28
|
+
'surfaces.discord.botToken',
|
|
29
|
+
'surfaces.ntfy.token',
|
|
30
|
+
'surfaces.webhook.secret',
|
|
31
|
+
'surfaces.homeassistant.accessToken',
|
|
32
|
+
'surfaces.homeassistant.webhookSecret',
|
|
33
|
+
'surfaces.telegram.botToken',
|
|
34
|
+
'surfaces.telegram.webhookSecret',
|
|
35
|
+
'surfaces.googleChat.verificationToken',
|
|
36
|
+
'surfaces.signal.token',
|
|
37
|
+
'surfaces.whatsapp.accessToken',
|
|
38
|
+
'surfaces.whatsapp.verifyToken',
|
|
39
|
+
'surfaces.whatsapp.signingSecret',
|
|
40
|
+
'surfaces.imessage.token',
|
|
41
|
+
'surfaces.msteams.appPassword',
|
|
42
|
+
'surfaces.bluebubbles.password',
|
|
43
|
+
'surfaces.mattermost.botToken',
|
|
44
|
+
'surfaces.matrix.accessToken',
|
|
45
|
+
// Local synthetic sub-keys, one level under the SDK's real `payments`
|
|
46
|
+
// section (not yet a scalar CONFIG_SCHEMA entry — same situation as
|
|
47
|
+
// tts.speed, behavior.notifyAfterSeconds, etc. in settings-modal-data.ts),
|
|
48
|
+
// hence the cast. See input/payments-config.ts for why these are named flat
|
|
49
|
+
// (payments.cardNumber, not payments.card.number): a flat one-level leaf is
|
|
50
|
+
// the shape the real ConfigManager tolerates for an undeclared key.
|
|
51
|
+
'payments.cardNumber' as ConfigKey,
|
|
52
|
+
'payments.cardExpiry' as ConfigKey,
|
|
53
|
+
'payments.cardCvv' as ConfigKey,
|
|
54
|
+
'payments.cardholderName' as ConfigKey,
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
export interface SecretBackedConfigUpdate {
|
|
58
|
+
readonly configValue: string;
|
|
59
|
+
readonly secretKey?: string;
|
|
60
|
+
readonly secretValue?: string;
|
|
61
|
+
readonly clearSecretKey?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SecretBackedConfigManager {
|
|
65
|
+
readonly get: (key: ConfigKey) => unknown;
|
|
66
|
+
readonly setDynamic: (key: ConfigKey, value: unknown) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface SecretBackedSecretStore {
|
|
70
|
+
readonly set: (key: string, value: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
|
|
71
|
+
readonly delete?: (key: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isSecretConfigKey(key: string): key is ConfigKey {
|
|
75
|
+
return SECRET_CONFIG_KEYS.has(key as ConfigKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeSecretKeyPart(value: string): string {
|
|
79
|
+
return value
|
|
80
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
81
|
+
.replace(/[^a-zA-Z0-9]+/g, '_')
|
|
82
|
+
.replace(/^_+|_+$/g, '')
|
|
83
|
+
.toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildGoodVibesSecretKey(configKey: string): string {
|
|
87
|
+
return `GOODVIBES_${configKey.split('.').map(normalizeSecretKeyPart).filter(Boolean).join('_')}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function buildGoodVibesSecretRef(secretKey: string): string {
|
|
91
|
+
return `goodvibes://secrets/goodvibes/${encodeURIComponent(secretKey)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function isSecretReferenceValue(value: string): boolean {
|
|
95
|
+
const normalized = value.trim();
|
|
96
|
+
return normalized.startsWith('goodvibes://secrets/') && isSecretRefInput(normalized);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
|
|
100
|
+
const normalized = value.trim();
|
|
101
|
+
return normalized.startsWith('goodvibes://') && !isSecretReferenceValue(normalized);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getSecretWriteMedium(policy: unknown): SecretStorageMedium {
|
|
105
|
+
if (policy === 'plaintext_allowed') return 'plaintext';
|
|
106
|
+
return 'secure';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: string): SecretBackedConfigUpdate {
|
|
110
|
+
const value = rawValue.trim();
|
|
111
|
+
const secretKey = buildGoodVibesSecretKey(configKey);
|
|
112
|
+
if (value.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
configValue: '',
|
|
115
|
+
clearSecretKey: secretKey,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (isSecretReferenceValue(value)) {
|
|
119
|
+
return { configValue: value };
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
configValue: buildGoodVibesSecretRef(secretKey),
|
|
123
|
+
secretKey,
|
|
124
|
+
secretValue: rawValue,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Where a secret-backed write lands when the caller did not name a scope.
|
|
130
|
+
*
|
|
131
|
+
* A daemon-owned config key (`surfaces.*`, `payments.*`, `controlPlane.*`, ...)
|
|
132
|
+
* names a credential the DAEMON executes with, not this interactive client, so
|
|
133
|
+
* its secret material belongs in the daemon-scoped tier the daemon actually
|
|
134
|
+
* reads — the same rule the SDK's config-ownership.ts already applies to the
|
|
135
|
+
* `goodvibes://` reference that points at it.
|
|
136
|
+
*
|
|
137
|
+
* Defaulting these to 'user' (the historical behavior here) split the pair: the
|
|
138
|
+
* reference landed in the daemon's own settings file, because ConfigManager
|
|
139
|
+
* routes daemon-owned keys there, while the value it pointed at sat in a tier
|
|
140
|
+
* the daemon never resolves. The surface reported success and the daemon found
|
|
141
|
+
* nothing. For the mailbox password that is the whole feature failing silently —
|
|
142
|
+
* the daemon is the process that polls IMAP and answers over Telegram, and it
|
|
143
|
+
* does so with every surface closed. A payment card entered through
|
|
144
|
+
* /payments card is the same shape of failure at purchase time.
|
|
145
|
+
*/
|
|
146
|
+
export function defaultSecretBackedScope(configKey: ConfigKey): SecretScope {
|
|
147
|
+
return isDaemonOwnedConfigKey(configKey) ? 'daemon' : 'user';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function persistSecretBackedConfigValue(
|
|
151
|
+
configManager: SecretBackedConfigManager,
|
|
152
|
+
secretsManager: SecretBackedSecretStore | null | undefined,
|
|
153
|
+
configKey: ConfigKey,
|
|
154
|
+
rawValue: string,
|
|
155
|
+
options: { readonly scope?: SecretScope } = {},
|
|
156
|
+
): Promise<string> {
|
|
157
|
+
const update = buildSecretBackedConfigUpdate(configKey, rawValue);
|
|
158
|
+
const scope = options.scope ?? defaultSecretBackedScope(configKey);
|
|
159
|
+
const medium = getSecretWriteMedium(configManager.get('storage.secretPolicy'));
|
|
160
|
+
|
|
161
|
+
// 1. Validate config write first. If setDynamic throws, no secret is written (avoids orphans).
|
|
162
|
+
configManager.setDynamic(configKey, update.configValue);
|
|
163
|
+
|
|
164
|
+
// 2. Write new secret only after config accepted it.
|
|
165
|
+
if (update.secretKey && update.secretValue !== undefined && secretsManager) {
|
|
166
|
+
await secretsManager.set(update.secretKey, update.secretValue, { scope, medium });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Clear old secret — pass the same medium so plaintext-medium secrets are found for deletion.
|
|
170
|
+
if (update.clearSecretKey && secretsManager?.delete) {
|
|
171
|
+
await secretsManager.delete(update.clearSecretKey, { scope, medium });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return update.configValue;
|
|
175
|
+
}
|