@moxxy/plugin-channel-signal 0.27.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.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel/chunker.d.ts +69 -0
  3. package/dist/channel/chunker.d.ts.map +1 -0
  4. package/dist/channel/chunker.js +133 -0
  5. package/dist/channel/chunker.js.map +1 -0
  6. package/dist/channel/turn-runner.d.ts +33 -0
  7. package/dist/channel/turn-runner.d.ts.map +1 -0
  8. package/dist/channel/turn-runner.js +70 -0
  9. package/dist/channel/turn-runner.js.map +1 -0
  10. package/dist/channel/voice.d.ts +37 -0
  11. package/dist/channel/voice.d.ts.map +1 -0
  12. package/dist/channel/voice.js +88 -0
  13. package/dist/channel/voice.js.map +1 -0
  14. package/dist/channel.d.ts +154 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +468 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +28 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +215 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/jsonrpc.d.ts +53 -0
  23. package/dist/jsonrpc.d.ts.map +1 -0
  24. package/dist/jsonrpc.js +167 -0
  25. package/dist/jsonrpc.js.map +1 -0
  26. package/dist/keys.d.ts +37 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +55 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/pair-flow.d.ts +21 -0
  31. package/dist/pair-flow.d.ts.map +1 -0
  32. package/dist/pair-flow.js +136 -0
  33. package/dist/pair-flow.js.map +1 -0
  34. package/dist/permission.d.ts +21 -0
  35. package/dist/permission.d.ts.map +1 -0
  36. package/dist/permission.js +27 -0
  37. package/dist/permission.js.map +1 -0
  38. package/dist/schema.d.ts +4295 -0
  39. package/dist/schema.d.ts.map +1 -0
  40. package/dist/schema.js +84 -0
  41. package/dist/schema.js.map +1 -0
  42. package/dist/setup-wizard.d.ts +14 -0
  43. package/dist/setup-wizard.d.ts.map +1 -0
  44. package/dist/setup-wizard.js +85 -0
  45. package/dist/setup-wizard.js.map +1 -0
  46. package/dist/sidecar.d.ts +137 -0
  47. package/dist/sidecar.d.ts.map +1 -0
  48. package/dist/sidecar.js +421 -0
  49. package/dist/sidecar.js.map +1 -0
  50. package/package.json +89 -0
  51. package/src/channel/chunker.test.ts +135 -0
  52. package/src/channel/chunker.ts +147 -0
  53. package/src/channel/turn-runner.ts +97 -0
  54. package/src/channel/voice.test.ts +136 -0
  55. package/src/channel/voice.ts +118 -0
  56. package/src/channel.test.ts +364 -0
  57. package/src/channel.ts +607 -0
  58. package/src/index.ts +289 -0
  59. package/src/jsonrpc.test.ts +128 -0
  60. package/src/jsonrpc.ts +198 -0
  61. package/src/keys.test.ts +45 -0
  62. package/src/keys.ts +56 -0
  63. package/src/pair-flow.ts +161 -0
  64. package/src/permission.ts +36 -0
  65. package/src/schema.ts +98 -0
  66. package/src/setup-wizard.ts +98 -0
  67. package/src/sidecar.test.ts +276 -0
  68. package/src/sidecar.ts +511 -0
  69. package/src/subcommands.test.ts +206 -0
package/src/keys.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Vault keys + validation shared by the channel, its subcommands, and the
3
+ * interactive setup wizard. Kept in their own module (the telegram/slack
4
+ * `keys.ts` pattern) so wizard / pair-flow helpers can import them without
5
+ * pulling in the plugin's full index.
6
+ *
7
+ * Note on secret custody: signal-cli keeps the actual Signal identity keys +
8
+ * message store in ITS OWN data dir (`$XDG_DATA_HOME/signal-cli/`, i.e.
9
+ * `~/.local/share/signal-cli/` by default — see {@link signalCliDataDir}).
10
+ * The moxxy vault only stores the account NUMBER and the sender allow-list;
11
+ * there is no API token in this channel at all.
12
+ */
13
+
14
+ /** Vault key for the linked Signal account number (E.164). */
15
+ export const SIGNAL_ACCOUNT_KEY = 'signal_account';
16
+ /** Env override for the account number — beats the vault (shared precedence). */
17
+ export const SIGNAL_ACCOUNT_ENV = 'MOXXY_SIGNAL_ACCOUNT';
18
+ /**
19
+ * Vault key for the sender allow-list: a JSON array of E.164 numbers and/or
20
+ * Signal account UUIDs that may drive the session. The linked account's own
21
+ * "Note to Self" is allowed implicitly and does not need an entry here.
22
+ */
23
+ export const SIGNAL_ALLOWED_SENDERS_KEY = 'signal_allowed_senders';
24
+
25
+ /** E.164 shape: `+` then 7–15 digits, no leading zero. */
26
+ export const E164_RE = /^\+[1-9]\d{6,14}$/;
27
+
28
+ /** Loose UUID shape (Signal ACIs are UUIDv4; accept any hex-dash UUID). */
29
+ export const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
30
+
31
+ /** Canonical sender identity for allow-list comparisons: trimmed, UUIDs lowercased. */
32
+ export function normalizeSender(raw: string): string {
33
+ const trimmed = raw.trim();
34
+ return UUID_RE.test(trimmed) ? trimmed.toLowerCase() : trimmed;
35
+ }
36
+
37
+ /**
38
+ * Parse the stored allow-list. Returns `[]` for a missing OR corrupt value
39
+ * rather than throwing — a corrupt vault entry must degrade to "nobody extra
40
+ * is allowed" (fail closed), never crash the channel or silently allow.
41
+ * Non-string entries and entries that look like neither an E.164 number nor a
42
+ * UUID are dropped.
43
+ */
44
+ export function parseAllowedSenders(raw: string | null | undefined): string[] {
45
+ if (!raw) return [];
46
+ try {
47
+ const parsed: unknown = JSON.parse(raw);
48
+ if (!Array.isArray(parsed)) return [];
49
+ return parsed
50
+ .filter((x): x is string => typeof x === 'string')
51
+ .map(normalizeSender)
52
+ .filter((x) => E164_RE.test(x) || UUID_RE.test(x));
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
@@ -0,0 +1,161 @@
1
+ import { log, outro, spinner } from '@clack/prompts';
2
+ import QRCode from 'qrcode';
3
+ import { exitAfterPairRequested, type ChannelSubcommandContext } from '@moxxy/sdk';
4
+ import type { VaultStore } from '@moxxy/plugin-vault';
5
+ import { SignalChannel } from './channel.js';
6
+
7
+ // Tiny zero-dep ANSI dim helper, so this flow stays inside the plugin.
8
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
9
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
10
+
11
+ /**
12
+ * Drive the Signal linked-device pairing end-to-end in a terminal — the SAME
13
+ * mechanism the desktop Channels panel uses (the channel's `pair: true` link
14
+ * window), just rendered inline here.
15
+ *
16
+ * Steps:
17
+ * 1. Build a SignalChannel from the subcommand ctx and wire the session's
18
+ * permission resolver.
19
+ * 2. Subscribe to "linked" BEFORE starting so a fast scan can't race us.
20
+ * 3. Start the channel in pairing mode — it spawns `signal-cli link` and
21
+ * publishes the `sgnl://linkdevice…` URI as `channel.requestUrl`.
22
+ * 4. Render that URI as a scannable QR (+ the raw URI).
23
+ * 5. On the phone: Signal → Settings → Linked Devices → Link New Device →
24
+ * scan; the channel stores the account and boots the daemon.
25
+ * 6. Keep the channel running until the user Ctrl-Cs.
26
+ */
27
+ export async function runSignalPairFlow(
28
+ ctx: ChannelSubcommandContext,
29
+ overrides: { readonly allowedTools?: ReadonlyArray<string> } = {},
30
+ ): Promise<number> {
31
+ const session = ctx.session;
32
+ const channel = new SignalChannel({
33
+ vault: ctx.deps.vault as VaultStore,
34
+ ...(typeof ctx.deps.options?.['account'] === 'string'
35
+ ? { account: ctx.deps.options['account'] as string }
36
+ : {}),
37
+ ...(overrides.allowedTools ? { allowedTools: overrides.allowedTools } : {}),
38
+ logger: ctx.deps.logger as never,
39
+ });
40
+ session.setPermissionResolver(channel.permissionResolver);
41
+
42
+ // Subscribe BEFORE start so the first scan can't fire before us.
43
+ let linkedResolve: ((account: string) => void) | null = null;
44
+ const linkedPromise = new Promise<string>((resolve) => {
45
+ linkedResolve = resolve;
46
+ });
47
+ const unsubscribe = channel.onLinked((account) => {
48
+ linkedResolve?.(account);
49
+ linkedResolve = null;
50
+ });
51
+
52
+ outro(dim('opening Signal linking window...'));
53
+
54
+ const handle = await channel.start({ session, pair: true });
55
+
56
+ const stopChannel = async (): Promise<void> => {
57
+ unsubscribe();
58
+ try {
59
+ await handle.stop('pair-flow');
60
+ } catch {
61
+ /* ignore */
62
+ }
63
+ };
64
+
65
+ // Already linked (no window was opened): nothing to scan.
66
+ if (channel.connected) {
67
+ log.info(
68
+ 'This machine is already linked to a Signal account. The channel is running; ' +
69
+ 'to link a different account, remove this device from Signal → Linked Devices first, ' +
70
+ 'then run `moxxy channels signal unpair` and pair again.',
71
+ );
72
+ if (exitAfterPairRequested(ctx)) {
73
+ await stopChannel();
74
+ return 0;
75
+ }
76
+ log.info('Press Ctrl+C to stop.');
77
+ installSignalHandlers(stopChannel, session);
78
+ await handle.running;
79
+ return 0;
80
+ }
81
+
82
+ const uri = channel.requestUrl;
83
+ if (!uri) {
84
+ log.error('Could not obtain a linking URI from signal-cli.');
85
+ await stopChannel();
86
+ return 1;
87
+ }
88
+
89
+ await printLinkQr(uri);
90
+ log.info(
91
+ 'On your phone: Signal → Settings → Linked Devices → Link New Device, then scan the QR. ' +
92
+ 'The QR expires after a few minutes; re-run `moxxy channels signal pair` if it does.',
93
+ );
94
+
95
+ const removeSignalHandlers = installSignalHandlers(stopChannel, session);
96
+
97
+ const spin = spinner();
98
+ spin.start('Waiting for you to scan in Signal...');
99
+ const account = await Promise.race([
100
+ linkedPromise,
101
+ handle.running.then(() => null as string | null),
102
+ ]).catch((err: unknown) => {
103
+ spin.stop('Linking failed.');
104
+ log.error(err instanceof Error ? err.message : String(err));
105
+ return null;
106
+ });
107
+ if (account == null) {
108
+ await stopChannel();
109
+ return 1;
110
+ }
111
+ spin.stop(`Linked ✓ — this machine is now a device of ${account}.`);
112
+
113
+ if (exitAfterPairRequested(ctx)) {
114
+ // Orchestrated pairing (`moxxy onboard`): hand control back — the caller
115
+ // starts the channel under its own service afterwards. Our SIGINT
116
+ // handlers would `process.exit` the orchestrator, so drop them first.
117
+ removeSignalHandlers();
118
+ await stopChannel();
119
+ return 0;
120
+ }
121
+
122
+ log.info(
123
+ 'Message your own "Note to Self" in Signal to talk to moxxy. The channel is running; press Ctrl+C to stop.',
124
+ );
125
+
126
+ await handle.running;
127
+ return 0;
128
+ }
129
+
130
+ function installSignalHandlers(
131
+ stopChannel: () => Promise<void>,
132
+ session: ChannelSubcommandContext['session'],
133
+ ): () => void {
134
+ let stopping = false;
135
+ const shutdown = async (): Promise<void> => {
136
+ if (stopping) return;
137
+ stopping = true;
138
+ await stopChannel();
139
+ await session.close('SIGINT').catch(() => undefined);
140
+ process.exit(0);
141
+ };
142
+ const onSignal = (): void => void shutdown();
143
+ process.once('SIGINT', onSignal);
144
+ process.once('SIGTERM', onSignal);
145
+ return () => {
146
+ process.removeListener('SIGINT', onSignal);
147
+ process.removeListener('SIGTERM', onSignal);
148
+ };
149
+ }
150
+
151
+ /** Render the linking URI as a scannable terminal QR + the raw URI. */
152
+ async function printLinkQr(uri: string): Promise<void> {
153
+ let qr = '';
154
+ try {
155
+ qr = await QRCode.toString(uri, { type: 'terminal', small: true });
156
+ } catch {
157
+ qr = '';
158
+ }
159
+ // CLI surface — intentional stdout.
160
+ console.log(['', qr, ` link URI: ${uri}`, ''].join('\n'));
161
+ }
@@ -0,0 +1,36 @@
1
+ import { createAuditedAllowListResolver } from '@moxxy/channel-kit';
2
+ import type { PermissionResolver } from '@moxxy/sdk';
3
+
4
+ export interface SignalPermissionLogger {
5
+ info?(msg: string, meta?: Record<string, unknown>): void;
6
+ }
7
+
8
+ /**
9
+ * Build the Signal channel's autonomous permission resolver.
10
+ *
11
+ * Like Slack (and unlike Telegram's inline-keyboard prompts), the Signal
12
+ * channel runs hands-off: the operator declares trust upfront via
13
+ * `channels.signal.allowedTools`, and any tool NOT in that list is denied.
14
+ * The trust check + `'*'` expansion + audit-on-auto-approve wiring is the
15
+ * shared {@link createAuditedAllowListResolver} from `@moxxy/channel-kit`;
16
+ * this wrapper binds it to the channel logger so every auto-approved call
17
+ * leaves a trail. An empty list denies everything (effectively read-only).
18
+ */
19
+ export function buildSignalPermissionResolver(opts: {
20
+ allowedTools: ReadonlyArray<string>;
21
+ allToolNames: ReadonlyArray<string>;
22
+ logger?: SignalPermissionLogger;
23
+ }): PermissionResolver {
24
+ return createAuditedAllowListResolver({
25
+ name: 'signal-allow-list',
26
+ allowedTools: opts.allowedTools,
27
+ allToolNames: opts.allToolNames,
28
+ onAutoApprove: (call, { wildcard }) => {
29
+ opts.logger?.info?.('signal: auto-approved tool call', {
30
+ tool: call.name,
31
+ callId: call.callId,
32
+ wildcard,
33
+ });
34
+ },
35
+ });
36
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod validation for signal-cli `receive` notification params — every inbound
5
+ * envelope is validated + size-capped BEFORE any field reaches the session
6
+ * (channel invariant A8). We only type the fields the channel consumes;
7
+ * unknown extras pass through untouched so a newer signal-cli doesn't fail
8
+ * validation, but nothing un-modeled is ever read.
9
+ */
10
+
11
+ /** Cap on inbound prompt text we will forward to the model. */
12
+ export const MAX_INBOUND_TEXT_CHARS = 16_000;
13
+
14
+ /**
15
+ * Attachment ids become filenames under signal-cli's attachments dir; a strict
16
+ * charset (no separators, no dots-only names) makes path traversal impossible
17
+ * before we ever join() it.
18
+ */
19
+ export const attachmentIdSchema = z
20
+ .string()
21
+ .min(1)
22
+ .max(256)
23
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'invalid attachment id');
24
+
25
+ export const attachmentSchema = z
26
+ .object({
27
+ id: attachmentIdSchema.optional(),
28
+ contentType: z.string().max(128).optional(),
29
+ filename: z.string().max(512).nullable().optional(),
30
+ size: z.number().int().nonnegative().optional(),
31
+ })
32
+ .passthrough();
33
+
34
+ export type SignalAttachment = z.infer<typeof attachmentSchema>;
35
+
36
+ const groupInfoSchema = z
37
+ .object({
38
+ groupId: z.string().max(256).optional(),
39
+ })
40
+ .passthrough();
41
+
42
+ const dataMessageSchema = z
43
+ .object({
44
+ timestamp: z.number().int().optional(),
45
+ message: z.string().max(MAX_INBOUND_TEXT_CHARS).nullable().optional(),
46
+ groupInfo: groupInfoSchema.nullable().optional(),
47
+ attachments: z.array(attachmentSchema).max(16).optional(),
48
+ })
49
+ .passthrough();
50
+
51
+ /**
52
+ * A sync'd copy of a message the ACCOUNT OWNER sent from another of their
53
+ * devices (their phone, Signal Desktop, …). "Note to Self" prompts arrive this
54
+ * way — and so can echoes of our OWN sends, which is why the channel filters
55
+ * these by sent-timestamp before acting (loop protection).
56
+ */
57
+ const sentMessageSchema = z
58
+ .object({
59
+ timestamp: z.number().int().optional(),
60
+ message: z.string().max(MAX_INBOUND_TEXT_CHARS).nullable().optional(),
61
+ destination: z.string().max(128).nullable().optional(),
62
+ destinationNumber: z.string().max(32).nullable().optional(),
63
+ destinationUuid: z.string().max(64).nullable().optional(),
64
+ groupInfo: groupInfoSchema.nullable().optional(),
65
+ attachments: z.array(attachmentSchema).max(16).optional(),
66
+ })
67
+ .passthrough();
68
+
69
+ const syncMessageSchema = z
70
+ .object({
71
+ sentMessage: sentMessageSchema.optional(),
72
+ })
73
+ .passthrough();
74
+
75
+ export const envelopeSchema = z
76
+ .object({
77
+ source: z.string().max(128).nullable().optional(),
78
+ sourceNumber: z.string().max(32).nullable().optional(),
79
+ sourceUuid: z.string().max(64).nullable().optional(),
80
+ sourceName: z.string().max(256).nullable().optional(),
81
+ sourceDevice: z.number().int().optional(),
82
+ timestamp: z.number().int().optional(),
83
+ dataMessage: dataMessageSchema.nullable().optional(),
84
+ syncMessage: syncMessageSchema.nullable().optional(),
85
+ // typingMessage / receiptMessage / receiptMessage etc. pass through and are
86
+ // simply not consumed.
87
+ })
88
+ .passthrough();
89
+
90
+ export const receiveParamsSchema = z
91
+ .object({
92
+ account: z.string().max(64).optional(),
93
+ envelope: envelopeSchema,
94
+ })
95
+ .passthrough();
96
+
97
+ export type SignalEnvelope = z.infer<typeof envelopeSchema>;
98
+ export type SignalReceiveParams = z.infer<typeof receiveParamsSchema>;
@@ -0,0 +1,98 @@
1
+ import { cancel, intro, isCancel, log, note, outro, text } from '@clack/prompts';
2
+ import type { ChannelSubcommandContext } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import { E164_RE, SIGNAL_ACCOUNT_KEY } from './keys.js';
5
+ import { SIGNAL_CLI_INSTALL_HINT, findSignalCliOnPath, listSignalAccounts } from './sidecar.js';
6
+ import { runSignalPairFlow } from './pair-flow.js';
7
+
8
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
9
+ const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
10
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
11
+
12
+ /**
13
+ * Interactive Signal setup wizard (the channel's `interactiveCommand`).
14
+ *
15
+ * Walks the operator through:
16
+ * 1. verify the `signal-cli` binary is installed (install hint when missing),
17
+ * 2. store the account number (E.164) in the vault — no API token exists for
18
+ * Signal; identity comes from device linking,
19
+ * 3. choose the autonomous tool allow-list,
20
+ * 4. hand off to the pair flow (QR link) when the account isn't linked yet,
21
+ * or start the channel directly when it is.
22
+ */
23
+ export async function runSignalWizard(ctx: ChannelSubcommandContext): Promise<number> {
24
+ const vault = ctx.deps.vault as VaultStore;
25
+ intro(bold('moxxy signal setup'));
26
+
27
+ const binary = findSignalCliOnPath();
28
+ if (!binary) {
29
+ log.error(SIGNAL_CLI_INSTALL_HINT);
30
+ return 1;
31
+ }
32
+ log.success(`Found signal-cli: ${binary}`);
33
+
34
+ note(
35
+ 'moxxy joins your Signal account as a LINKED DEVICE (like Signal Desktop).\n' +
36
+ 'It will see the messages your account receives, so it runs on its own\n' +
37
+ 'dedicated, isolated runner. After linking, message your own "Note to Self"\n' +
38
+ 'to talk to moxxy; other senders must be allow-listed explicitly.',
39
+ 'how the Signal channel works',
40
+ );
41
+
42
+ const existing = await vault.get(SIGNAL_ACCOUNT_KEY);
43
+ const account = await text({
44
+ message: 'Your Signal account number (E.164)',
45
+ placeholder: '+15551234567',
46
+ ...(existing ? { initialValue: existing } : {}),
47
+ validate: (v) => {
48
+ if (!v || !v.trim()) return 'required';
49
+ if (!E164_RE.test(v.trim())) return 'expected E.164, e.g. +15551234567';
50
+ return undefined;
51
+ },
52
+ });
53
+ if (isCancel(account)) {
54
+ cancel('cancelled.');
55
+ return 0;
56
+ }
57
+ const accountNumber = String(account).trim();
58
+ await vault.set(SIGNAL_ACCOUNT_KEY, accountNumber, ['signal']);
59
+ log.success(`Stored account ${accountNumber} in the vault.`);
60
+
61
+ const allow = await text({
62
+ message: 'Autonomous tool allow-list (comma-separated; "*" = all, blank = read-only)',
63
+ placeholder: 'Read, Grep, Glob',
64
+ });
65
+ if (isCancel(allow)) {
66
+ cancel('cancelled.');
67
+ return 0;
68
+ }
69
+ const allowedTools = String(allow)
70
+ .split(',')
71
+ .map((s) => s.trim())
72
+ .filter(Boolean);
73
+
74
+ // Linked already? (one-shot listAccounts spawn; on probe failure fall through
75
+ // to the pair flow, which handles the already-linked case gracefully too).
76
+ let linked = false;
77
+ try {
78
+ linked = (await listSignalAccounts({ binary })).includes(accountNumber);
79
+ } catch {
80
+ linked = false;
81
+ }
82
+
83
+ if (linked) {
84
+ log.info('This machine is already linked. Starting the channel…');
85
+ outro(dim('handing off to the channel…'));
86
+ return ctx.startChannel({ allowedTools });
87
+ }
88
+
89
+ note(
90
+ 'Next: link this machine to your Signal account. A QR will appear —\n' +
91
+ 'on your phone open Signal → Settings → Linked Devices → Link New Device\n' +
92
+ 'and scan it. signal-cli keeps the linked-device keys in\n' +
93
+ '~/.local/share/signal-cli/ (moxxy stores only the number + allow-list).',
94
+ 'link your phone',
95
+ );
96
+ outro(dim('opening the linking QR…'));
97
+ return runSignalPairFlow(ctx, { allowedTools });
98
+ }