@pellux/goodvibes-tui 1.25.0 → 1.26.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 +15 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +6616 -2776
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +10 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +48 -4
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -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/input/commands/config.ts +68 -2
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands.ts +7 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +17 -2
- package/src/input/settings-modal.ts +27 -107
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-helpers.ts +4 -0
- package/src/renderer/settings-modal.ts +16 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +14 -205
- package/src/verification/verification-ledger.ts +104 -4
- package/src/version.ts +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* failure-text.ts — what the operator is told when a send does not happen.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not `summarizeError`
|
|
5
|
+
*
|
|
6
|
+
* The SDK's `summarizeError` is the right tool nearly everywhere: it classifies
|
|
7
|
+
* an error, adds a hint, and deliberately drops detail so a transcript stays
|
|
8
|
+
* readable. That is the wrong trade here, and measurably so. Given the real
|
|
9
|
+
* Telegram failure
|
|
10
|
+
*
|
|
11
|
+
* Telegram delivery failed HTTP 401: {"ok":false,"description":"Unauthorized"}
|
|
12
|
+
*
|
|
13
|
+
* it returns `Telegram delivery failed HTTP 401` — its `stripJson` pass removes
|
|
14
|
+
* the `{...}`, which is exactly the part naming what went wrong. Given
|
|
15
|
+
* `connect ECONNREFUSED 149.154.167.220:443` it returns "Cannot connect to the
|
|
16
|
+
* provider. Check whether the service is reachable." — advice in place of the
|
|
17
|
+
* address that was refused.
|
|
18
|
+
*
|
|
19
|
+
* A person debugging a message that did not arrive needs the provider's own
|
|
20
|
+
* words: "Unauthorized", "chat not found", and "Bad Request: message is too
|
|
21
|
+
* long" have three different fixes, and all three flatten to the same summary.
|
|
22
|
+
*
|
|
23
|
+
* ## What is removed, and why that is not the same thing
|
|
24
|
+
*
|
|
25
|
+
* Detail is kept; CREDENTIALS are not. This matters more here than in most
|
|
26
|
+
* places because Telegram puts the bot token in the URL PATH
|
|
27
|
+
* (`api.telegram.org/bot<token>/sendMessage`), so a transport error that echoes
|
|
28
|
+
* the request URL would print the owner's bot token to a terminal and into
|
|
29
|
+
* whatever log or pasted report that terminal output ends up in.
|
|
30
|
+
*
|
|
31
|
+
* `redactSensitiveData` from the SDK covers the shapes it knows (bearer tokens,
|
|
32
|
+
* `sk-`/`xoxb-`/`ghp_` keys, home directories). It has NO pattern for a
|
|
33
|
+
* URL-embedded credential, so the two passes below add them. They run BEFORE
|
|
34
|
+
* the SDK pass, because a redacted string must not then be re-scanned in a way
|
|
35
|
+
* that could reveal structure.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { redactSensitiveData } from '@pellux/goodvibes-sdk/platform/utils';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A generous cap. The point of this text is diagnosis, so it is far larger than
|
|
42
|
+
* the 240 characters `summarizeError` allows — but an HTML error page or a
|
|
43
|
+
* multi-megabyte body still must not flood the terminal.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_FAILURE_TEXT = 2_000;
|
|
46
|
+
|
|
47
|
+
const URL_CREDENTIAL_PATTERNS: ReadonlyArray<{ readonly pattern: RegExp; readonly replacement: string }> = [
|
|
48
|
+
// Telegram: the bot token IS the path segment.
|
|
49
|
+
{ pattern: /\/bot\d{5,}:[A-Za-z0-9_-]{10,}/g, replacement: '/bot[REDACTED_BOT_TOKEN]' },
|
|
50
|
+
// Any URL carrying `user:password@host` — BlueBubbles, Mattermost and Matrix
|
|
51
|
+
// base URLs are all operator-supplied and can be written this way.
|
|
52
|
+
{ pattern: /(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi, replacement: '$1[REDACTED_CREDENTIALS]@' },
|
|
53
|
+
// A credential passed as a query parameter — BlueBubbles sends `?password=`.
|
|
54
|
+
{
|
|
55
|
+
pattern: /([?&](?:password|token|secret|access_token|api_?key|auth)=)[^&\s"']+/gi,
|
|
56
|
+
replacement: '$1[REDACTED]',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Strip credentials this command can put on the wire but the SDK pass does not know. */
|
|
61
|
+
export function redactUrlCredentials(text: string): string {
|
|
62
|
+
let result = text;
|
|
63
|
+
for (const { pattern, replacement } of URL_CREDENTIAL_PATTERNS) {
|
|
64
|
+
result = result.replace(pattern, replacement);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The provider's own failure text, with credentials removed and nothing else
|
|
71
|
+
* rewritten.
|
|
72
|
+
*
|
|
73
|
+
* Never returns an empty string: an error whose message is blank still has to
|
|
74
|
+
* produce something an operator can act on, because the alternative is an exit
|
|
75
|
+
* code with no explanation beside it.
|
|
76
|
+
*/
|
|
77
|
+
export function describeSendFailure(error: unknown): string {
|
|
78
|
+
const raw = error instanceof Error
|
|
79
|
+
? (error.message.trim().length > 0 ? error.message : error.name)
|
|
80
|
+
: typeof error === 'string'
|
|
81
|
+
? error
|
|
82
|
+
: (() => { try { return JSON.stringify(error); } catch { return String(error); } })();
|
|
83
|
+
const message = typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : 'the provider failed without saying why';
|
|
84
|
+
// `cause` is where Node and Bun put the real reason for `fetch failed`, and
|
|
85
|
+
// dropping it is how a genuinely diagnosable DNS or TLS failure becomes two
|
|
86
|
+
// useless words.
|
|
87
|
+
const cause = error instanceof Error && error.cause instanceof Error && error.cause.message.trim().length > 0
|
|
88
|
+
? ` (${error.cause.message.trim()})`
|
|
89
|
+
: '';
|
|
90
|
+
const full = `${message}${cause}`;
|
|
91
|
+
const redacted = redactSensitiveData(redactUrlCredentials(full));
|
|
92
|
+
return redacted.length <= MAX_FAILURE_TEXT ? redacted : `${redacted.slice(0, MAX_FAILURE_TEXT)}…`;
|
|
93
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inert-text.ts — make a message arrive as LITERAL TEXT on whichever channel
|
|
3
|
+
* `goodvibes-daemon send` delivers it to.
|
|
4
|
+
*
|
|
5
|
+
* ## What "inert" means here, and why it is per (surface × transport), not
|
|
6
|
+
* per surface
|
|
7
|
+
*
|
|
8
|
+
* The transform a surface needs depends on what the delivery strategy in
|
|
9
|
+
* `platform/channels/delivery/strategies-core.ts` actually does with the body,
|
|
10
|
+
* not on what the surface is capable of parsing. Two examples that pull in
|
|
11
|
+
* opposite directions:
|
|
12
|
+
*
|
|
13
|
+
* - Telegram's `sendMessage` CAN parse MarkdownV2, but the strategy sends with
|
|
14
|
+
* NO `parse_mode`, which is Telegram's plain-text mode. Running a MarkdownV2
|
|
15
|
+
* escaper over the body on this path would not protect anything — it would
|
|
16
|
+
* put visible backslashes in front of every `.` `-` `!` `(` in the owner's
|
|
17
|
+
* message. The correct transform is identity, and the property that makes it
|
|
18
|
+
* correct is asserted by a test against the real wire payload rather than
|
|
19
|
+
* asserted here in prose (see src/test/daemon/send-command-wire.test.ts).
|
|
20
|
+
*
|
|
21
|
+
* - Discord's `postMessage` renders markdown unconditionally, INCLUDING masked
|
|
22
|
+
* links, so the same body needs real escaping.
|
|
23
|
+
*
|
|
24
|
+
* A single "escape for channel X" table that ignored the transport would get
|
|
25
|
+
* Telegram wrong in the direction that mangles the owner's text, and would get
|
|
26
|
+
* Discord wrong in the direction that renders a link. Hence the split below.
|
|
27
|
+
*
|
|
28
|
+
* ## Provenance of the escaper bodies
|
|
29
|
+
*
|
|
30
|
+
* `escapeDiscordMarkdown` and `escapeSlackMrkdwn` are the escapers written for
|
|
31
|
+
* the inbound-mail structured notices, in the SDK at
|
|
32
|
+
* `packages/sdk/src/platform/email/inbound-notice.ts` — same character classes,
|
|
33
|
+
* same zero-width-space mention break, same rationale. They are reproduced here
|
|
34
|
+
* rather than imported for one reason: that module lives on the unmerged
|
|
35
|
+
* `inbound-email-*` branches and is absent from the published
|
|
36
|
+
* `@pellux/goodvibes-sdk@1.18.1` this product consumes, so there is nothing to
|
|
37
|
+
* import yet.
|
|
38
|
+
*
|
|
39
|
+
* This is a second copy and that is a defect, not a design. When the
|
|
40
|
+
* inbound-mail round merges and an SDK release publishes `platform/email`'s
|
|
41
|
+
* notice escapers, THIS FILE'S `escapeDiscordMarkdown` and `escapeSlackMrkdwn`
|
|
42
|
+
* are to be deleted and the SDK's imported in their place; the surface-kind
|
|
43
|
+
* table below is the only part that should survive that change. The behaviour
|
|
44
|
+
* is pinned by tests either way, so the swap is verifiable rather than hopeful.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { ChannelDeliverySurfaceKind } from '@pellux/goodvibes-sdk/platform/channels';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Zero-width space. Used to break a token that a surface would otherwise read
|
|
51
|
+
* as syntax, on surfaces that offer no backslash escape (Slack) or where
|
|
52
|
+
* backslash-escaping demonstrably does not defeat the token (Discord mentions).
|
|
53
|
+
*/
|
|
54
|
+
const ZERO_WIDTH_SPACE = '';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `@everyone`, `@here`, and raw role/channel/user mentions. Backslash-escaping
|
|
58
|
+
* the `@` does NOT reliably suppress these in every Discord client, so the
|
|
59
|
+
* zero-width break is used instead.
|
|
60
|
+
*/
|
|
61
|
+
function breakMentionForms(text: string): string {
|
|
62
|
+
return text
|
|
63
|
+
.replace(/@(everyone|here)/g, (_match, word: string) => `@${ZERO_WIDTH_SPACE}${word}`)
|
|
64
|
+
.replace(/<@[!&]?(\d+)>/g, (_match, id: string) => `<@${ZERO_WIDTH_SPACE}${id}>`)
|
|
65
|
+
.replace(/<#(\d+)>/g, (_match, id: string) => `<#${ZERO_WIDTH_SPACE}${id}>`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Discord markdown. Backslash-escaping `* _ ~ \` |` and `>` renders each as its
|
|
70
|
+
* literal character rather than triggering bold/italic/strikethrough/code/
|
|
71
|
+
* spoiler/quote.
|
|
72
|
+
*
|
|
73
|
+
* `[` `]` `(` `)` are escaped too, and this is REQUIRED rather than
|
|
74
|
+
* precautionary: masked links (`[text](url)`) DO render as clickable in
|
|
75
|
+
* bot-sent and webhook messages — which is exactly how this product delivers to
|
|
76
|
+
* Discord — even though they do not render for text a human typed into the
|
|
77
|
+
* client.
|
|
78
|
+
* https://github.com/discord/discord-api-docs/issues/6096
|
|
79
|
+
* https://gist.github.com/matthewzring/9f7bbfd102003963f9be7dbcf7d40e51
|
|
80
|
+
*
|
|
81
|
+
* Without this, a message body assembled from anything the owner did not type
|
|
82
|
+
* arrives in his Discord as a clickable link reading whatever the body said.
|
|
83
|
+
* An escaper that looks optional gets tidied away by the next reader: it is not
|
|
84
|
+
* optional, and it stays.
|
|
85
|
+
*
|
|
86
|
+
* `\` is inside the character class, so a backslash in the input is escaped
|
|
87
|
+
* first and cannot un-escape what follows it.
|
|
88
|
+
*/
|
|
89
|
+
function escapeDiscordMarkdown(text: string): string {
|
|
90
|
+
return breakMentionForms(text.replace(/[*_~`|>[\]()\\]/g, (ch) => `\\${ch}`));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Slack mrkdwn. `&`, `<`, `>` MUST be HTML-entity-escaped per Slack's own
|
|
95
|
+
* formatting reference. That is also what defeats `<url|text>` link syntax and
|
|
96
|
+
* `<!channel>` / `<@id>` mention syntax outright, since both require a literal
|
|
97
|
+
* unescaped `<`.
|
|
98
|
+
*
|
|
99
|
+
* Slack has no backslash escape for `* _ ~ \``, so the zero-width break is
|
|
100
|
+
* applied to those as the best available mitigation — stated as a mitigation,
|
|
101
|
+
* not a guarantee, because Slack's whitespace-adjacency rule for what breaks a
|
|
102
|
+
* delimiter pair is not publicly specified to that precision. The residual risk
|
|
103
|
+
* is cosmetic (accidental bold/italic); the injection class this exists to
|
|
104
|
+
* close — a clickable link or a real mention — is closed by the entity escaping
|
|
105
|
+
* above.
|
|
106
|
+
*/
|
|
107
|
+
function escapeSlackMrkdwn(text: string): string {
|
|
108
|
+
const entityEscaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
109
|
+
return entityEscaped.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Google Chat's `text` field renders `*bold*`, `_italic_`, `~strike~`,
|
|
114
|
+
* `` `code` `` and `<url|text>` links. The `<` entity escape is what defeats
|
|
115
|
+
* the link and `<users/all>` mention forms; the delimiters get the zero-width
|
|
116
|
+
* break, as Google Chat documents no backslash escape either.
|
|
117
|
+
*/
|
|
118
|
+
function escapeGoogleChatMarkup(text: string): string {
|
|
119
|
+
const entityEscaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
120
|
+
return entityEscaped.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* WhatsApp Cloud API text messages render `*bold*`, `_italic_`, `~strike~` and
|
|
125
|
+
* ```` ```mono``` ````. There is no masked-link syntax — a URL in the body is
|
|
126
|
+
* auto-linked showing its real address — so the injection class Discord has
|
|
127
|
+
* does not exist here and only the delimiters need neutralizing. WhatsApp
|
|
128
|
+
* documents no backslash escape, so the zero-width break is used.
|
|
129
|
+
*/
|
|
130
|
+
function escapeWhatsAppMarkup(text: string): string {
|
|
131
|
+
return text.replace(/[*_~`]/g, (ch) => `${ch}${ZERO_WIDTH_SPACE}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The surfaces whose delivery strategy hands the body to a renderer that parses
|
|
136
|
+
* markup. Every other routable surface is absent DELIBERATELY, and each absence
|
|
137
|
+
* is a checked claim about the strategy, not an oversight:
|
|
138
|
+
*
|
|
139
|
+
* - `telegram` — `sendMessage` without `parse_mode`; Telegram's plain-text
|
|
140
|
+
* mode. Escaping here would corrupt, not protect.
|
|
141
|
+
* - `ntfy` — `publish` sends the body as `text/plain` and never sets the
|
|
142
|
+
* `Markdown` header, so ntfy renders it literally. The title
|
|
143
|
+
* is derived by `titleFromBody` (first non-empty LINE) and
|
|
144
|
+
* passed through `toHeaderSafeTitle`, so no line break or
|
|
145
|
+
* non-ASCII byte from the body can reach an HTTP header.
|
|
146
|
+
* - `webhook` — the body is a JSON string field; the receiver decides what
|
|
147
|
+
* to do with it and there is no markup layer to neutralize.
|
|
148
|
+
*
|
|
149
|
+
* - `signal` — the strategy posts `text` to a signal bridge. Signal renders
|
|
150
|
+
* no markup in a plain message body; styling travels as
|
|
151
|
+
* explicit range metadata the strategy never sends.
|
|
152
|
+
* - `imessage`,
|
|
153
|
+
* `bluebubbles` — both end at iMessage, which renders no markup at all.
|
|
154
|
+
* - `msteams` — the strategy sends `textFormat: 'plain'` alongside the text,
|
|
155
|
+
* which is Teams' own instruction not to parse it.
|
|
156
|
+
* - `matrix` — `msgtype: 'm.text'` with no `format`/`formatted_body`. A
|
|
157
|
+
* Matrix event without the HTML format field is rendered
|
|
158
|
+
* literally by clients, per the spec.
|
|
159
|
+
*
|
|
160
|
+
* `web` is absent for a different reason: its strategy needs a live
|
|
161
|
+
* `ControlPlaneGateway` in the same process, which a short-lived CLI does not
|
|
162
|
+
* have, so the command does not offer it as a channel at all. `telephony` is
|
|
163
|
+
* absent because a CLI `send` to it would place a phone call or an SMS through
|
|
164
|
+
* a paid carrier — a different act from messaging a channel — and because the
|
|
165
|
+
* strategy already XML-escapes the voice path itself via `escapeTwiml`.
|
|
166
|
+
*/
|
|
167
|
+
const INERT_TRANSFORMS: Partial<Record<ChannelDeliverySurfaceKind, (text: string) => string>> = {
|
|
168
|
+
// Renders markup, including a masked link: full escaping.
|
|
169
|
+
discord: escapeDiscordMarkdown,
|
|
170
|
+
mattermost: escapeDiscordMarkdown,
|
|
171
|
+
// Renders markup, but has no masked-link syntax: delimiters only.
|
|
172
|
+
slack: escapeSlackMrkdwn,
|
|
173
|
+
'google-chat': escapeGoogleChatMarkup,
|
|
174
|
+
whatsapp: escapeWhatsAppMarkup,
|
|
175
|
+
// Delivered as plain text by the strategy: transforming would corrupt, not
|
|
176
|
+
// protect. Each of these is a checked claim about the strategy, not a guess —
|
|
177
|
+
// see the list above.
|
|
178
|
+
telegram: (text) => text,
|
|
179
|
+
ntfy: (text) => text,
|
|
180
|
+
webhook: (text) => text,
|
|
181
|
+
signal: (text) => text,
|
|
182
|
+
imessage: (text) => text,
|
|
183
|
+
bluebubbles: (text) => text,
|
|
184
|
+
msteams: (text) => text,
|
|
185
|
+
matrix: (text) => text,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** Surfaces `goodvibes-daemon send` will deliver to, in a stable display order. */
|
|
189
|
+
export const INERT_RENDERABLE_SURFACE_KINDS: readonly ChannelDeliverySurfaceKind[] = [
|
|
190
|
+
'telegram', 'ntfy', 'discord', 'slack', 'google-chat', 'webhook',
|
|
191
|
+
'signal', 'whatsapp', 'imessage', 'msteams', 'bluebubbles', 'mattermost', 'matrix',
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether this surface has a verified inert transform.
|
|
196
|
+
*
|
|
197
|
+
* A surface with no entry gets a refusal from the send command, never an
|
|
198
|
+
* untransformed body. Returning the body unchanged for an unknown surface is
|
|
199
|
+
* precisely how a markup-rendering channel acquires a live link: the default
|
|
200
|
+
* has to be "refuse", and it has to be checkable from the caller.
|
|
201
|
+
*/
|
|
202
|
+
export function canRenderInert(surfaceKind: ChannelDeliverySurfaceKind): boolean {
|
|
203
|
+
return Object.prototype.hasOwnProperty.call(INERT_TRANSFORMS, surfaceKind);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Render `text` so it arrives on `surfaceKind` as the literal characters the
|
|
208
|
+
* caller supplied.
|
|
209
|
+
*
|
|
210
|
+
* Throws for a surface with no verified transform. This function has no
|
|
211
|
+
* "pass it through" branch and must never grow one: every caller of the send
|
|
212
|
+
* command reaches the world through here, so a permissive fallback would make
|
|
213
|
+
* the command a way to put live markup on the owner's phone.
|
|
214
|
+
*/
|
|
215
|
+
export function inertBodyFor(surfaceKind: ChannelDeliverySurfaceKind, text: string): string {
|
|
216
|
+
const transform = INERT_TRANSFORMS[surfaceKind];
|
|
217
|
+
if (!transform) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`No verified inert-text transform for surface '${surfaceKind}'. `
|
|
220
|
+
+ 'Sending would risk the message being rendered as markup rather than as text, '
|
|
221
|
+
+ `so nothing was sent. Supported: ${INERT_RENDERABLE_SURFACE_KINDS.join(', ')}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return transform(text);
|
|
225
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdin.ts — read the whole of stdin as the message body.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of command.ts so the command stays a pure function of its arguments
|
|
5
|
+
* and its injected dependencies: every test drives it with a stub rather than
|
|
6
|
+
* having to arrange a real pipe on the process running the suite.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read stdin to end, as UTF-8.
|
|
11
|
+
*
|
|
12
|
+
* There is deliberately no timeout. `send` reads stdin only when the operator
|
|
13
|
+
* gave no message argument AND stdin is not a terminal — meaning something is
|
|
14
|
+
* piping into it — so waiting for that producer to finish is the correct
|
|
15
|
+
* behaviour, and cutting it off at an arbitrary deadline would silently
|
|
16
|
+
* truncate a long message.
|
|
17
|
+
*/
|
|
18
|
+
export async function readAllStdin(): Promise<string> {
|
|
19
|
+
const chunks: Buffer[] = [];
|
|
20
|
+
for await (const chunk of process.stdin) {
|
|
21
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
24
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { CommandRegistry } from '../command-registry.ts';
|
|
1
|
+
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
2
2
|
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
3
|
import { setActiveThemeMode, type ThemeMode } from '../../renderer/theme.ts';
|
|
4
|
+
import {
|
|
5
|
+
buildGoodVibesSecretKey,
|
|
6
|
+
defaultSecretBackedScope,
|
|
7
|
+
isSecretConfigKey,
|
|
8
|
+
persistSecretBackedConfigValue,
|
|
9
|
+
} from '../../config/secret-config.ts';
|
|
10
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
4
11
|
|
|
5
12
|
export function registerConfigCommand(registry: CommandRegistry): void {
|
|
6
13
|
registry.register({
|
|
@@ -9,7 +16,7 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
9
16
|
description: 'Open the fullscreen configuration workspace, or set a key directly',
|
|
10
17
|
usage: '[category|key] | set <key> <value>',
|
|
11
18
|
argsHint: '[category|key] | set <key> <value>',
|
|
12
|
-
handler(args, ctx) {
|
|
19
|
+
async handler(args, ctx) {
|
|
13
20
|
// An earlier replay fix: `/config set <key> <value>` used to fall through to
|
|
14
21
|
// the workspace with the assignment silently ignored — the dishonest-
|
|
15
22
|
// fallthrough class. `set` is now a real verb.
|
|
@@ -20,6 +27,17 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
20
27
|
ctx.print('Usage: /config set <key> <value> — e.g. /config set display.themeMode light');
|
|
21
28
|
return;
|
|
22
29
|
}
|
|
30
|
+
// A credential key must never take the generic path below: setDynamic
|
|
31
|
+
// writes the value it is handed straight into a settings JSON file, so
|
|
32
|
+
// `/config set surfaces.email.password hunter2` used to put the mailbox
|
|
33
|
+
// password in cleartext on disk. The settings modal already routes these
|
|
34
|
+
// keys through the secret manager (settings-modal-secrets.ts); this
|
|
35
|
+
// command has to route them the same way, or the two ways of setting the
|
|
36
|
+
// same key disagree about where the value lands.
|
|
37
|
+
if (isSecretConfigKey(key)) {
|
|
38
|
+
await handleSecretConfigSet(ctx, key as ConfigKey, value);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
23
41
|
try {
|
|
24
42
|
const before = ctx.platform.configManager.get(key as ConfigKey);
|
|
25
43
|
ctx.platform.configManager.setDynamic(key as ConfigKey, coerceConfigValue(value));
|
|
@@ -49,6 +67,54 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
49
67
|
});
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Store a credential-bearing config key through the secret manager instead of
|
|
72
|
+
* writing it into a config file: the value goes to the secret store and the
|
|
73
|
+
* config key keeps only a `goodvibes://secrets/...` reference to it.
|
|
74
|
+
*
|
|
75
|
+
* The entered value is never echoed. The transcript is a diagnostic surface —
|
|
76
|
+
* it is scrolled back through, copied into support threads and captured in
|
|
77
|
+
* screen recordings — so it gets the reference and the store key, never the
|
|
78
|
+
* secret.
|
|
79
|
+
*
|
|
80
|
+
* With no secret manager in this runtime there is nowhere safe to put it, and
|
|
81
|
+
* the command REFUSES rather than falling back to a plaintext config write.
|
|
82
|
+
*/
|
|
83
|
+
async function handleSecretConfigSet(
|
|
84
|
+
ctx: CommandContext,
|
|
85
|
+
key: ConfigKey,
|
|
86
|
+
value: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const secretKey = buildGoodVibesSecretKey(key);
|
|
89
|
+
const secretsManager = ctx.platform?.secretsManager;
|
|
90
|
+
if (!secretsManager) {
|
|
91
|
+
ctx.print(
|
|
92
|
+
`${key} holds a credential, and this runtime has no secret store to put it in — `
|
|
93
|
+
+ `refusing rather than writing it in cleartext into a config file.\n`
|
|
94
|
+
+ ` Store it with: /secrets set ${secretKey} <value>`,
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const configValue = await persistSecretBackedConfigValue(
|
|
100
|
+
ctx.platform.configManager,
|
|
101
|
+
secretsManager,
|
|
102
|
+
key,
|
|
103
|
+
value,
|
|
104
|
+
);
|
|
105
|
+
const scope = defaultSecretBackedScope(key);
|
|
106
|
+
ctx.print(
|
|
107
|
+
`Set ${key}: stored in the ${scope} secret tier as ${secretKey} (value hidden).\n`
|
|
108
|
+
+ ` ${key} now holds the reference ${configValue} — the value itself is never written to a config file.`
|
|
109
|
+
+ (scope === 'daemon'
|
|
110
|
+
? '\n The daemon reads that tier, so this keeps working with this client closed.'
|
|
111
|
+
: ''),
|
|
112
|
+
);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
ctx.print(`Could not set ${key}: ${summarizeError(e)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
52
118
|
/** Coerce CLI text to the JSON-ish scalar the config schema expects. */
|
|
53
119
|
function coerceConfigValue(raw: string): unknown {
|
|
54
120
|
if (raw === 'true') return true;
|
|
@@ -130,7 +130,15 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
|
|
|
130
130
|
}
|
|
131
131
|
void (async () => {
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
// Daemon scope, stated explicitly here rather than left to the
|
|
134
|
+
// platform's daemon-owned credential registry: that registry
|
|
135
|
+
// recognises credentials by NAME, and this name is derived
|
|
136
|
+
// from an operator-chosen provider name
|
|
137
|
+
// (customProviderKeyName above), so no shipped list can
|
|
138
|
+
// contain it. The daemon is what runs the model, so the key
|
|
139
|
+
// has to reach the daemon tier or a locally-hosted endpoint
|
|
140
|
+
// is usable only while this TUI is open.
|
|
141
|
+
await secretsManager.set(keyName, value, { scope: 'daemon', medium: 'secure' });
|
|
134
142
|
ctx.print(`Stored the API key for '${name}' (value hidden). It resolves live for this provider — no restart needed.`);
|
|
135
143
|
} catch (error) {
|
|
136
144
|
ctx.print(`Could not store the key for '${name}': ${summarizeError(error)}`);
|
|
@@ -65,7 +65,12 @@ export function registerOperatorRuntimeCommands(registry: CommandRegistry): void
|
|
|
65
65
|
|
|
66
66
|
registry.register({
|
|
67
67
|
name: 'profiles',
|
|
68
|
-
|
|
68
|
+
// The singular `profile` alias was given up to /profile, the owner profile
|
|
69
|
+
// (docs/owner-profile.md). These are two unrelated things — this command
|
|
70
|
+
// loads saved display/provider settings presets, and the other one is what
|
|
71
|
+
// the platform knows about the person who owns it — and the singular word
|
|
72
|
+
// now belongs to the second. `/profiles` is unchanged; only the abbreviation
|
|
73
|
+
// moved. See the naming note in docs/owner-profile.md §3.
|
|
69
74
|
description: 'Browse and load config profiles',
|
|
70
75
|
handler(_args, ctx) {
|
|
71
76
|
if (ctx.openProfilePicker) {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /payments card — capture a payment card through the composer's concealed
|
|
3
|
+
* input mode, following the same guided-chain pattern as /channel pair
|
|
4
|
+
* (channel-pairing.ts) and the SAME concealed-input mechanism as
|
|
5
|
+
* provider-key-intake.ts (see concealed-input.ts).
|
|
6
|
+
*
|
|
7
|
+
* Card number, expiry, CVV, and cardholder name are secret-tier (see
|
|
8
|
+
* config/secret-config.ts's SECRET_CONFIG_KEYS): each is entered through one
|
|
9
|
+
* masked concealed-input prompt in turn and stored via
|
|
10
|
+
* persistSecretBackedConfigValue, the same secret-manager + config-reference
|
|
11
|
+
* path every other secret-backed setting in this app uses. The plaintext
|
|
12
|
+
* never reaches the transcript, input history, or a log line — only a
|
|
13
|
+
* redacted confirmation is printed after each field.
|
|
14
|
+
*
|
|
15
|
+
* Billing and shipping address are ordinary (non-secret) config fields, now
|
|
16
|
+
* real structured CONFIG_SCHEMA entries (payments.billingAddress.{name,
|
|
17
|
+
* line1, line2, city, region, postalCode, country} and the shippingAddress
|
|
18
|
+
* equivalents) — this command points the user at the Payments settings
|
|
19
|
+
* category (or /config payments) to set them, the same way /channel pair
|
|
20
|
+
* directs non-secret credential fields, rather than routing plain text
|
|
21
|
+
* through the concealed (masked) input path where it does not belong.
|
|
22
|
+
*
|
|
23
|
+
* Stored with `scope: 'daemon'`: the daemon — not just this interactive
|
|
24
|
+
* client — is what completes an autonomous purchase, so the credential must
|
|
25
|
+
* land in the daemon-scoped secret tier the daemon actually reads.
|
|
26
|
+
*
|
|
27
|
+
* The config-reference half (the `goodvibes://secrets/...` marker
|
|
28
|
+
* persistSecretBackedConfigValue writes alongside the secret) goes through
|
|
29
|
+
* `ctx.platform.configManager` directly — the same real ConfigManager every
|
|
30
|
+
* other command in this app uses, not a TUI-local store. See
|
|
31
|
+
* payments-config.ts's header comment for why these particular keys
|
|
32
|
+
* (`payments.cardNumber`, etc., flat sub-keys under the SDK's real
|
|
33
|
+
* `payments` section) are ConfigManager-safe even though the SDK's
|
|
34
|
+
* CONFIG_SCHEMA has no scalar entry for them yet: `payments.` is a
|
|
35
|
+
* daemon-owned config prefix, so the reference lands in the daemon's own
|
|
36
|
+
* settings file and is visible to the daemon, the webui and the agent —
|
|
37
|
+
* not just this TUI session.
|
|
38
|
+
*
|
|
39
|
+
* ── Card details are entered only at a local terminal or the webui ────────
|
|
40
|
+
*
|
|
41
|
+
* Owner ruling (see the SDK's platform/payments/entry-surface.ts): card
|
|
42
|
+
* material may be TYPED only on `tui`, `agent-terminal`, or `webui` — never
|
|
43
|
+
* over Telegram, ntfy, Discord, Slack, WhatsApp, Signal, a webhook, or any
|
|
44
|
+
* other remote messaging surface. A card number typed into a hosted chat is
|
|
45
|
+
* stored on that provider's own servers, in history this app cannot erase,
|
|
46
|
+
* and it already passed through their infrastructure before reaching us —
|
|
47
|
+
* our own encryption at rest does nothing for a value copied elsewhere on
|
|
48
|
+
* its way in. This is a SEPARATE question from which surfaces may APPROVE or
|
|
49
|
+
* VETO a purchase (every command-authority channel still can); the two are
|
|
50
|
+
* never merged into one check.
|
|
51
|
+
*
|
|
52
|
+
* This command always runs inside the TUI's own composer (CARD_ENTRY_SURFACE
|
|
53
|
+
* below), which the SDK's allowlist accepts — so this gate changes nothing
|
|
54
|
+
* about today's behavior. What it buys is that the refusal already exists
|
|
55
|
+
* the moment this app (or a shared command registry) ever grows a path that
|
|
56
|
+
* lets `/payments card` be reached from somewhere else: the check is real
|
|
57
|
+
* and SDK-driven, not an implicit "this is the TUI so it's fine" assumption.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import type { CommandRegistry, CommandContext } from '../command-registry.ts';
|
|
61
|
+
import { persistSecretBackedConfigValue } from '../../config/secret-config.ts';
|
|
62
|
+
import type { ConfigKey } from '../../config/index.ts';
|
|
63
|
+
import { describeCardEntryRefusal, mayOfferCardEntryFlow } from '@pellux/goodvibes-sdk/platform/payments';
|
|
64
|
+
import {
|
|
65
|
+
PAYMENTS_CARD_NUMBER_CONFIG_KEY,
|
|
66
|
+
PAYMENTS_CARD_EXPIRY_CONFIG_KEY,
|
|
67
|
+
PAYMENTS_CARD_CVV_CONFIG_KEY,
|
|
68
|
+
PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY,
|
|
69
|
+
} from '../payments-config.ts';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This command is wired only into the TUI's own composer command registry —
|
|
73
|
+
* it is never reachable from a channel bridge (Telegram, Discord, ...), each
|
|
74
|
+
* of which has its own separate command surface in the daemon. The constant
|
|
75
|
+
* still names the surface explicitly and is checked through the SDK's own
|
|
76
|
+
* allowlist below, rather than skipping the check because "this is the TUI".
|
|
77
|
+
*/
|
|
78
|
+
export const CARD_ENTRY_SURFACE = 'tui';
|
|
79
|
+
|
|
80
|
+
interface CardField {
|
|
81
|
+
readonly key: ConfigKey;
|
|
82
|
+
readonly label: string;
|
|
83
|
+
readonly placeholder: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Order matters only for the guided prompt sequence, not for storage. */
|
|
87
|
+
const CARD_SECRET_FIELDS: readonly CardField[] = [
|
|
88
|
+
{ key: PAYMENTS_CARD_NUMBER_CONFIG_KEY, label: 'Card number', placeholder: '4242424242424242' },
|
|
89
|
+
{ key: PAYMENTS_CARD_EXPIRY_CONFIG_KEY, label: 'Expiry (MM/YY)', placeholder: '12/34' },
|
|
90
|
+
{ key: PAYMENTS_CARD_CVV_CONFIG_KEY, label: 'CVV', placeholder: '123' },
|
|
91
|
+
{ key: PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY, label: 'Cardholder name', placeholder: 'as printed on the card' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function fieldConfigured(ctx: CommandContext, field: CardField): boolean {
|
|
95
|
+
const raw: unknown = ctx.platform.configManager.get(field.key);
|
|
96
|
+
return typeof raw === 'string' && raw.trim().length > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderCardStatus(ctx: CommandContext): string[] {
|
|
100
|
+
const lines = ['Payment card on file:'];
|
|
101
|
+
for (const field of CARD_SECRET_FIELDS) {
|
|
102
|
+
lines.push(` ${field.label.padEnd(20)} ${fieldConfigured(ctx, field) ? 'set' : 'not set'}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push('Run /payments card to enter or replace the card (masked input, chained prompts).');
|
|
106
|
+
lines.push('Billing/shipping address, budgets, windows and the rest of the payment capability are ordinary config values: set them via /config payments or the Settings > Payments category.');
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Chain a masked prompt for each card secret field, storing each through the daemon-scoped secret tier. */
|
|
111
|
+
function promptCardFields(ctx: CommandContext, fields: readonly CardField[], index: number): void {
|
|
112
|
+
if (index >= fields.length) {
|
|
113
|
+
ctx.print('\nCard stored. Billing/shipping address and the rest of the payment capability are ordinary config values — set them via /config payments or the Settings > Payments category.');
|
|
114
|
+
ctx.renderRequest();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const field = fields[index]!;
|
|
118
|
+
if (!ctx.beginConcealedInput) {
|
|
119
|
+
ctx.print('[payments] Concealed input is unavailable on this surface; card entry requires it and cannot fall back to plaintext.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
ctx.print(`[payments] Enter ${field.label} (e.g. ${field.placeholder}) — masked; Enter to store, Esc to stop.`);
|
|
123
|
+
ctx.beginConcealedInput({
|
|
124
|
+
label: field.label,
|
|
125
|
+
onSubmit: (value) => {
|
|
126
|
+
if (value.length === 0) {
|
|
127
|
+
ctx.print(`[payments] ${field.label} left unset.`);
|
|
128
|
+
promptCardFields(ctx, fields, index + 1);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
void persistSecretBackedConfigValue(
|
|
132
|
+
ctx.platform.configManager,
|
|
133
|
+
ctx.platform.secretsManager,
|
|
134
|
+
field.key,
|
|
135
|
+
value,
|
|
136
|
+
{ scope: 'daemon' },
|
|
137
|
+
)
|
|
138
|
+
.then(() => {
|
|
139
|
+
ctx.print(`[payments] ${field.label} stored securely (hidden).`);
|
|
140
|
+
promptCardFields(ctx, fields, index + 1);
|
|
141
|
+
})
|
|
142
|
+
.catch((error: unknown) => {
|
|
143
|
+
ctx.print(`[payments] Failed to store ${field.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
promptCardFields(ctx, fields, index + 1);
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
onCancel: () => {
|
|
148
|
+
ctx.print(`[payments] Stopped. Re-run /payments card to finish the remaining fields.`);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start the card-entry flow, gated on the SDK's own entry-surface allowlist
|
|
155
|
+
* rather than an assumption baked into this command. `surface` defaults to
|
|
156
|
+
* this command's real, fixed identity (CARD_ENTRY_SURFACE) — exposed as a
|
|
157
|
+
* parameter only so tests can drive the refusal path without needing this
|
|
158
|
+
* app to actually be reachable from a remote channel.
|
|
159
|
+
*/
|
|
160
|
+
export function startCardEntryFlow(ctx: CommandContext, surface: string = CARD_ENTRY_SURFACE): void {
|
|
161
|
+
if (!mayOfferCardEntryFlow(surface)) {
|
|
162
|
+
ctx.print(describeCardEntryRefusal(surface));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!ctx.beginConcealedInput) {
|
|
166
|
+
ctx.print('[payments] Concealed input is unavailable on this surface.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
ctx.print(`Entering ${CARD_SECRET_FIELDS.length} card field(s) — masked; Esc to stop at any point.`);
|
|
170
|
+
promptCardFields(ctx, CARD_SECRET_FIELDS, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Entry point for `/payments [card|status]` — exported directly so tests can drive it without the registry. */
|
|
174
|
+
export function runPaymentsCommand(args: readonly string[], ctx: CommandContext): void {
|
|
175
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
176
|
+
if (sub === '' || sub === 'status') {
|
|
177
|
+
ctx.print(renderCardStatus(ctx).join('\n'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (sub === 'card') {
|
|
181
|
+
startCardEntryFlow(ctx);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
ctx.print('Usage: /payments [card|status]');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Card secret fields in prompt order — exported for tests that need to drive the chain field-by-field. */
|
|
188
|
+
export { CARD_SECRET_FIELDS };
|
|
189
|
+
|
|
190
|
+
export function registerPaymentCardCommands(registry: CommandRegistry): void {
|
|
191
|
+
registry.register({
|
|
192
|
+
name: 'payments',
|
|
193
|
+
description: 'Payment card on file for daemon-initiated purchases (card entry is masked input)',
|
|
194
|
+
usage: '/payments [card]',
|
|
195
|
+
argsHint: '[card]',
|
|
196
|
+
handler(args, ctx) {
|
|
197
|
+
runPaymentsCommand(args, ctx);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|