@chatlychat/cli 0.1.7 → 0.3.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 (35) hide show
  1. package/dist/commands/config.d.ts.map +1 -1
  2. package/dist/commands/config.js +29 -3
  3. package/dist/commands/config.js.map +1 -1
  4. package/dist/commands/listen-bot.d.ts +52 -0
  5. package/dist/commands/listen-bot.d.ts.map +1 -0
  6. package/dist/commands/listen-bot.js +253 -0
  7. package/dist/commands/listen-bot.js.map +1 -0
  8. package/dist/commands/listen-core.d.ts +107 -0
  9. package/dist/commands/listen-core.d.ts.map +1 -0
  10. package/dist/commands/listen-core.js +334 -0
  11. package/dist/commands/listen-core.js.map +1 -0
  12. package/dist/commands/listen-relay-options.d.ts +78 -0
  13. package/dist/commands/listen-relay-options.d.ts.map +1 -0
  14. package/dist/commands/listen-relay-options.js +195 -0
  15. package/dist/commands/listen-relay-options.js.map +1 -0
  16. package/dist/commands/listen-relay-sign.d.ts +36 -0
  17. package/dist/commands/listen-relay-sign.d.ts.map +1 -0
  18. package/dist/commands/listen-relay-sign.js +136 -0
  19. package/dist/commands/listen-relay-sign.js.map +1 -0
  20. package/dist/commands/listen-relay.d.ts +43 -0
  21. package/dist/commands/listen-relay.d.ts.map +1 -0
  22. package/dist/commands/listen-relay.js +434 -0
  23. package/dist/commands/listen-relay.js.map +1 -0
  24. package/dist/commands/listen.d.ts +20 -0
  25. package/dist/commands/listen.d.ts.map +1 -0
  26. package/dist/commands/listen.js +355 -0
  27. package/dist/commands/listen.js.map +1 -0
  28. package/dist/help.d.ts +1 -1
  29. package/dist/help.d.ts.map +1 -1
  30. package/dist/help.js +28 -1
  31. package/dist/help.js.map +1 -1
  32. package/dist/run.d.ts.map +1 -1
  33. package/dist/run.js +11 -1
  34. package/dist/run.js.map +1 -1
  35. package/package.json +2 -2
@@ -0,0 +1,36 @@
1
+ /** The three surfaces a relay carries. Values are the SDK's surface names. */
2
+ export declare const RELAY_KINDS: readonly ["ai_tool_call", "customer_card", "canvas_step"];
3
+ export type RelayKind = (typeof RELAY_KINDS)[number];
4
+ /**
5
+ * Header names this CLI owns on a relayed request.
6
+ *
7
+ * Stripped from whatever the frame carried before signing, so a server that
8
+ * got §5 wrong and sent the production signature cannot have it forwarded
9
+ * beside ours — a handler that reads the first `x-chatly-signature` it finds
10
+ * would then verify the server's MAC, which is over the PRODUCTION path, and
11
+ * fail for a reason nobody could see. `host` and `content-length` are
12
+ * `fetch`'s to set.
13
+ */
14
+ export declare const RELAY_OWNED_HEADERS: ReadonlySet<string>;
15
+ /**
16
+ * The signature headers for one relayed request, self-checked.
17
+ *
18
+ * Returns the headers to merge, or a message naming what failed — which can
19
+ * only be a bug in this CLI, since it signed the bytes itself.
20
+ */
21
+ export declare function signRelayedRequest(input: {
22
+ readonly kind: RelayKind;
23
+ readonly method: string;
24
+ /** The absolute LOCAL url this request is about to be sent to. */
25
+ readonly targetUrl: string;
26
+ readonly body: string;
27
+ readonly secret: string;
28
+ readonly nowMs: number;
29
+ }): {
30
+ ok: true;
31
+ headers: Record<string, string>;
32
+ } | {
33
+ ok: false;
34
+ message: string;
35
+ };
36
+ //# sourceMappingURL=listen-relay-sign.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listen-relay-sign.d.ts","sourceRoot":"","sources":["../../src/commands/listen-relay-sign.ts"],"names":[],"mappings":"AA2CA,8EAA8E;AAC9E,eAAO,MAAM,WAAW,2DAA4D,CAAC;AACrF,MAAM,MAAM,SAAS,GAAG,CAAC,OAAO,WAAW,CAAC,CAAC,MAAM,CAAC,CAAC;AAErD;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAQlD,CAAC;AAMH;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACxC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kEAAkE;IAClE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAgEjF"}
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The signature a relayed SYNCHRONOUS call carries when it reaches localhost.
3
+ *
4
+ * ═══ WHY THE CLI SIGNS AND THE SERVER DOES NOT ════════════════════════════
5
+ *
6
+ * The server holds the real secret for every one of these surfaces, so the
7
+ * obvious design is for it to sign the frame and for this CLI to forward it
8
+ * byte for byte. That is rejected in `RELAY-SERVER-SPEC.md` §5 and the reason
9
+ * is worth repeating where the signing happens: a frame carrying a
10
+ * production-valid signature is a replayable credential, delivered to anyone
11
+ * holding a `ck_` key with the relay permission. For a tool call the MAC
12
+ * covers the method and the path, so the replay is narrow — but for a
13
+ * customer-card fetch and a canvas step it covers `<ts>.<body>` only, and the
14
+ * same signed bytes are accepted by the operator's REAL endpoint for the next
15
+ * five minutes. So frames arrive unsigned and are signed here, with the
16
+ * secret the developer passes in `--secret`, exactly as `listen` and
17
+ * `listen --bot` already do for the ledgers.
18
+ *
19
+ * ═══ THE THREE CONSTRUCTIONS, AS THE SENDERS USE THEM ═════════════════════
20
+ *
21
+ * Read against the senders, not against the docs:
22
+ *
23
+ * | kind | sender | header(s) | base | `t` unit |
24
+ * | --------------- | --------------------------------------------------- | --------------------------------------------------- | ----------------------------- | -------- |
25
+ * | `ai_tool_call` | `dialCustomTool`, `packages/shared/src/ai-tools` | `x-chatly-signature: t=…,v1=…` | `<t>.<METHOD>.<path>.<body>` | seconds |
26
+ * | `customer_card` | `customer-cards.fetcher.ts` `attempt()` | `x-chatly-signature: sha256=…` + `x-chatly-timestamp` | `<ts>.<body>` | **ms** |
27
+ * | `canvas_step` | `widget-canvas.service.ts` `callIntegrator()` | `x-chatly-canvas-signature: sha256=…` + `-ts` | `<ts>.<body>` | **ms** |
28
+ *
29
+ * Two of the three put `x-chatly-signature` on the wire with DIFFERENT
30
+ * formats, and two send milliseconds where the third sends seconds. Both
31
+ * traps are why each header is checked with the SDK's own
32
+ * `verifyChatlySignature` under its own `surface` before the request goes
33
+ * out, and a forward that does not verify is refused rather than sent — the
34
+ * reasoning is `listen-core.ts`'s, and so is the unified
35
+ * `x-chatly-signature-v2` minted beside each one.
36
+ *
37
+ * `path` in the tool-call base string is the LOCAL path, because that is the
38
+ * request line the developer's handler verifies.
39
+ */
40
+ import { createHmac } from 'node:crypto';
41
+ import { chatlyV2SigningPath, signChatlyV2, verifyChatlySignature } from '@chatlychat/sdk-node';
42
+ /** The three surfaces a relay carries. Values are the SDK's surface names. */
43
+ export const RELAY_KINDS = ['ai_tool_call', 'customer_card', 'canvas_step'];
44
+ /**
45
+ * Header names this CLI owns on a relayed request.
46
+ *
47
+ * Stripped from whatever the frame carried before signing, so a server that
48
+ * got §5 wrong and sent the production signature cannot have it forwarded
49
+ * beside ours — a handler that reads the first `x-chatly-signature` it finds
50
+ * would then verify the server's MAC, which is over the PRODUCTION path, and
51
+ * fail for a reason nobody could see. `host` and `content-length` are
52
+ * `fetch`'s to set.
53
+ */
54
+ export const RELAY_OWNED_HEADERS = new Set([
55
+ 'x-chatly-signature',
56
+ 'x-chatly-signature-v2',
57
+ 'x-chatly-timestamp',
58
+ 'x-chatly-canvas-signature',
59
+ 'x-chatly-canvas-ts',
60
+ 'host',
61
+ 'content-length',
62
+ ]);
63
+ function hmacHex(secret, base) {
64
+ return createHmac('sha256', secret).update(base).digest('hex');
65
+ }
66
+ /**
67
+ * The signature headers for one relayed request, self-checked.
68
+ *
69
+ * Returns the headers to merge, or a message naming what failed — which can
70
+ * only be a bug in this CLI, since it signed the bytes itself.
71
+ */
72
+ export function signRelayedRequest(input) {
73
+ const { kind, body, secret, nowMs } = input;
74
+ const method = input.method.toUpperCase();
75
+ const path = chatlyV2SigningPath(input.targetUrl);
76
+ if (path === null) {
77
+ return { ok: false, message: `"${input.targetUrl}" is not a URL` };
78
+ }
79
+ const seconds = Math.floor(nowMs / 1000);
80
+ const headers = {};
81
+ if (kind === 'ai_tool_call') {
82
+ const t = String(seconds);
83
+ headers['x-chatly-signature'] =
84
+ `t=${t},v1=${hmacHex(secret, `${t}.${method}.${path}.${body}`)}`;
85
+ }
86
+ else {
87
+ // MILLISECONDS, as both senders do: `Date.now().toString()`.
88
+ const ts = String(Math.floor(nowMs));
89
+ const value = `sha256=${hmacHex(secret, `${ts}.${body}`)}`;
90
+ if (kind === 'customer_card') {
91
+ headers['x-chatly-timestamp'] = ts;
92
+ headers['x-chatly-signature'] = value;
93
+ }
94
+ else {
95
+ headers['x-chatly-canvas-ts'] = ts;
96
+ headers['x-chatly-canvas-signature'] = value;
97
+ }
98
+ }
99
+ const legacyCheck = verifyChatlySignature({
100
+ surface: kind,
101
+ body,
102
+ headers,
103
+ secret,
104
+ method,
105
+ path,
106
+ nowMs,
107
+ // This check is of the surface's OWN construction; v2 is checked below.
108
+ v2: 'ignore',
109
+ });
110
+ if (!legacyCheck.ok) {
111
+ return { ok: false, message: `${legacyCheck.reason}: ${legacyCheck.message}` };
112
+ }
113
+ const v2 = signChatlyV2({
114
+ base: 'target',
115
+ secret,
116
+ timestampSeconds: seconds,
117
+ method,
118
+ path,
119
+ body,
120
+ });
121
+ const v2Check = verifyChatlySignature({
122
+ surface: 'unified_v2',
123
+ body,
124
+ headers: { 'x-chatly-signature-v2': v2 },
125
+ secret,
126
+ method,
127
+ path,
128
+ nowMs,
129
+ });
130
+ if (!v2Check.ok) {
131
+ return { ok: false, message: `${v2Check.reason}: ${v2Check.message}` };
132
+ }
133
+ headers['x-chatly-signature-v2'] = v2;
134
+ return { ok: true, headers };
135
+ }
136
+ //# sourceMappingURL=listen-relay-sign.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listen-relay-sign.js","sourceRoot":"","sources":["../../src/commands/listen-relay-sign.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ import { type ChatlyClient } from '@chatlychat/sdk-node';
2
+ import { type RelayAnswer, type RelayFrame, type RelayOptions } from './listen-relay-options.js';
3
+ import type { ListenDeps } from './listen-core.js';
4
+ export { parseRelayOptions, readFrames, relayOnlyFlagWithoutRelay, relayTargetUrl, type RelayAnswer, type RelayFrame, type RelayOptions, type RelaySecrets, } from './listen-relay-options.js';
5
+ /** Detach after this long with no frame. See the header, rule 2. */
6
+ export declare const RELAY_IDLE_TIMEOUT_MS: number;
7
+ /** Long-poll wait. Under the SDK transport's 15s request timeout. */
8
+ export declare const RELAY_POLL_WAIT_SECONDS = 10;
9
+ /** A local answer larger than this is refused rather than relayed. */
10
+ export declare const RELAY_MAX_RESPONSE_BYTES = 1048576;
11
+ /** The protocol version this CLI speaks, sent on attach. */
12
+ export declare const RELAY_PROTOCOL_VERSION = 1;
13
+ export interface RelaySummary {
14
+ readonly frames: number;
15
+ readonly answered: number;
16
+ readonly failed: number;
17
+ readonly polls: number;
18
+ /** Why the session ended, in the server's vocabulary where it has one. */
19
+ readonly endReason: string;
20
+ }
21
+ /**
22
+ * Forward one frame to localhost and build the answer. Never throws: every
23
+ * failure is an answer, because the server is holding a visitor's request
24
+ * open until it gets one.
25
+ */
26
+ export declare function answerFrame(opts: RelayOptions, deps: ListenDeps, frame: RelayFrame, signal: AbortSignal): Promise<RelayAnswer>;
27
+ /**
28
+ * The 404 a server without this feature answers, as a sentence.
29
+ *
30
+ * Exported for the spec. A CLI released ahead of the API deploy gets this, so
31
+ * it has to say plainly that the route is missing rather than that the user
32
+ * typed something wrong.
33
+ */
34
+ export declare function explainMissingServer(err: unknown): Error | null;
35
+ /**
36
+ * The relay loop. Exported for the spec, bounded by `deps.maxPolls` there.
37
+ *
38
+ * Frames are answered CONCURRENTLY: each one has its own caller waiting with
39
+ * its own deadline, and a slow tool call must not make a card fetch behind it
40
+ * time out.
41
+ */
42
+ export declare function relay(chatly: ChatlyClient, opts: RelayOptions, deps: ListenDeps): Promise<RelaySummary>;
43
+ //# sourceMappingURL=listen-relay.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listen-relay.d.ts","sourceRoot":"","sources":["../../src/commands/listen-relay.ts"],"names":[],"mappings":"AAyDA,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEzE,OAAO,EAKL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,YAAY,EAClB,MAAM,2BAA2B,CAAC;AAInC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EACL,iBAAiB,EACjB,UAAU,EACV,yBAAyB,EACzB,cAAc,EACd,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,2BAA2B,CAAC;AAEnC,oEAAoE;AACpE,eAAO,MAAM,qBAAqB,QAAc,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAC1C,sEAAsE;AACtE,eAAO,MAAM,wBAAwB,UAAY,CAAC;AAClD,4DAA4D;AAC5D,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAYxC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AA8GD;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,UAAU,EAChB,KAAK,EAAE,UAAU,EACjB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,WAAW,CAAC,CAKtB;AAiCD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,KAAK,GAAG,IAAI,CAe/D;AA0DD;;;;;;GAMG;AACH,wBAAsB,KAAK,CACzB,MAAM,EAAE,YAAY,EACpB,IAAI,EAAE,YAAY,EAClB,IAAI,EAAE,UAAU,GACf,OAAO,CAAC,YAAY,CAAC,CAgKvB"}
@@ -0,0 +1,434 @@
1
+ /**
2
+ * `chatly listen --relay` — receive Chatly's SYNCHRONOUS calls on localhost.
3
+ *
4
+ * ═══ WHY A LEDGER TAIL CANNOT DO THIS ═════════════════════════════════════
5
+ *
6
+ * `listen` and `listen --bot` tail delivery LOGS: the platform has already
7
+ * made the request, and the CLI replays a copy. Three surfaces have no such
8
+ * log and could not use one if they did — an AI tool call, a customer-card
9
+ * fetch and a canvas step. Each is made INSIDE a visitor's request: Chatly
10
+ * calls the configured URL and waits for the answer, which is the point of
11
+ * the call (the tool result, the card's content, the next step). A copy that
12
+ * arrives afterwards is useless, because by then the visitor has been shown
13
+ * the configured URL's answer, or its timeout.
14
+ *
15
+ * So `--relay` opens an outbound session and, while it lives, the server
16
+ * sends calls for the ids named on the command line to this process instead
17
+ * of to the configured URL. The CLI forwards each to `--forward-to`, re-signed
18
+ * (`listen-relay-sign.ts` says why the server does not sign), and posts the
19
+ * answer back, and the server hands that answer to the waiting caller as if
20
+ * the configured URL had given it.
21
+ *
22
+ * ═══ WHICH HALF OF THIS IS REAL ═══════════════════════════════════════════
23
+ *
24
+ * BOTH. The server half is `apps/api/src/modules/relay/` (shipped 2026-10-15),
25
+ * built against `apps/cli/RELAY-SERVER-SPEC.md`, which stays the contract: if
26
+ * the two disagree, the spec is right and this is the bug. The wire types are
27
+ * `@livechat/shared/relay`, and `scripts/check-relay-protocol-parity.mjs`
28
+ * checks the fields this file reads against them.
29
+ *
30
+ * `explainMissingServer` stays anyway. A CLI release reaches users before an
31
+ * API deploy does, and a 404 on the first request is then the route being
32
+ * absent, not a typo — it needs a sentence that says nothing was redirected,
33
+ * rather than a bare "Not Found". (Chatly is a managed service, so there is no
34
+ * older self-hosted API to blame; the window is the deploy ordering.)
35
+ *
36
+ * ═══ WHY LONG-POLL AND NOT A WEBSOCKET ════════════════════════════════════
37
+ *
38
+ * The CLI declares one dependency, the published SDK, and runs on Node 20,
39
+ * which has no WebSocket client without a package. Server-Sent Events would
40
+ * need a second channel for the answers anyway. A long-poll goes through the
41
+ * SDK's own transport — auth, retries, typed errors — and its lease semantics
42
+ * are the teardown rule: a session nobody has polled for 30 seconds is dead.
43
+ * The poll waits 10 seconds because the SDK's request timeout is 15.
44
+ *
45
+ * ═══ THE TWO SAFETY RULES, AND WHERE THEY LIVE ════════════════════════════
46
+ *
47
+ * 1. **Explicit ids.** `--tool`, `--card`, `--canvas` — there is no
48
+ * "everything" form, because a relay takes PRODUCTION traffic. A frame
49
+ * for an id that was not asked for is answered with an error rather than
50
+ * forwarded: that would be a server bug routing a stranger's visitor to a
51
+ * developer's laptop, and the laptop is the last place it can be stopped.
52
+ * 2. **10 minutes idle.** Enforced by the server (authoritative) AND here,
53
+ * so a forgotten terminal detaches itself and says so. It is not a flag:
54
+ * a longer one is how a relay gets forgotten.
55
+ */
56
+ import { hostname, userInfo } from 'node:os';
57
+ import { ChatlyApiError } from '@chatlychat/sdk-node';
58
+ import { secretFor, selectedFor, relayTargetUrl, readFrames, } from './listen-relay-options.js';
59
+ import { RELAY_OWNED_HEADERS, signRelayedRequest } from './listen-relay-sign.js';
60
+ import { printJson } from '../output.js';
61
+ export { parseRelayOptions, readFrames, relayOnlyFlagWithoutRelay, relayTargetUrl, } from './listen-relay-options.js';
62
+ /** Detach after this long with no frame. See the header, rule 2. */
63
+ export const RELAY_IDLE_TIMEOUT_MS = 10 * 60_000;
64
+ /** Long-poll wait. Under the SDK transport's 15s request timeout. */
65
+ export const RELAY_POLL_WAIT_SECONDS = 10;
66
+ /** A local answer larger than this is refused rather than relayed. */
67
+ export const RELAY_MAX_RESPONSE_BYTES = 1_048_576;
68
+ /** The protocol version this CLI speaks, sent on attach. */
69
+ export const RELAY_PROTOCOL_VERSION = 1;
70
+ /**
71
+ * `relayAnswerSchema` caps an error answer's `message` at 2,000 characters.
72
+ * An `unreachable` message embeds the OS error and the URL, so a long one is
73
+ * possible — and over the cap the server answers 422, the answer is never
74
+ * recorded, and the visitor waits out the whole deadline instead of failing
75
+ * fast. Clamped here rather than trusting the message to be short.
76
+ */
77
+ const MAX_ERROR_MESSAGE_CHARS = 2_000;
78
+ /** Backoff ceiling for a poll that failed to reach the server. */
79
+ const MAX_BACKOFF_MS = 8_000;
80
+ /** The forward leg of `answerFrame`, before the message cap is applied. */
81
+ async function answerFrameUnclamped(opts, deps, frame, signal) {
82
+ const started = deps.now();
83
+ const elapsed = () => Math.max(0, deps.now() - started);
84
+ if (!selectedFor(opts, frame.kind).includes(frame.targetId)) {
85
+ return {
86
+ error: 'not_selected',
87
+ message: `this relay did not ask for ${frame.kind} ${frame.targetId}`,
88
+ durationMs: 0,
89
+ };
90
+ }
91
+ const url = relayTargetUrl(opts.forwardTo, frame.path);
92
+ const headers = {};
93
+ for (const [name, value] of Object.entries(frame.headers)) {
94
+ if (!RELAY_OWNED_HEADERS.has(name.toLowerCase())) {
95
+ headers[name.toLowerCase()] = value;
96
+ }
97
+ }
98
+ // Not a header Chatly sends; see listen-core.ts.
99
+ headers['x-chatly-cli-forwarded'] = '1';
100
+ const secret = secretFor(opts, frame.targetId);
101
+ if (secret !== null) {
102
+ const signed = signRelayedRequest({
103
+ kind: frame.kind,
104
+ method: frame.method,
105
+ targetUrl: url,
106
+ body: frame.body,
107
+ secret,
108
+ nowMs: deps.now(),
109
+ });
110
+ if (!signed.ok) {
111
+ return {
112
+ error: 'signing_failed',
113
+ message: `the signature chatly listen minted does not verify (${signed.message})`,
114
+ durationMs: 0,
115
+ };
116
+ }
117
+ Object.assign(headers, signed.headers);
118
+ }
119
+ // Abort at the caller's deadline: after it, nobody is waiting for this.
120
+ const budget = Date.parse(frame.deadlineAt) - deps.now();
121
+ const ctrl = new AbortController();
122
+ const onAbort = () => {
123
+ ctrl.abort();
124
+ };
125
+ signal.addEventListener('abort', onAbort, { once: true });
126
+ let timedOut = false;
127
+ const timer = setTimeout(() => {
128
+ timedOut = true;
129
+ ctrl.abort();
130
+ }, Math.max(0, budget));
131
+ try {
132
+ const method = frame.method.toUpperCase();
133
+ const res = await deps.fetchFn(url, {
134
+ method,
135
+ headers,
136
+ ...(method === 'GET' || method === 'HEAD' ? {} : { body: frame.body }),
137
+ signal: ctrl.signal,
138
+ });
139
+ const text = await res.text();
140
+ if (Buffer.byteLength(text, 'utf8') > RELAY_MAX_RESPONSE_BYTES) {
141
+ return {
142
+ error: 'too_large',
143
+ message: `your handler answered more than ${String(RELAY_MAX_RESPONSE_BYTES)} bytes`,
144
+ durationMs: elapsed(),
145
+ };
146
+ }
147
+ const contentType = res.headers.get('content-type');
148
+ return {
149
+ status: res.status,
150
+ headers: contentType === null ? {} : { 'content-type': contentType },
151
+ body: text,
152
+ durationMs: elapsed(),
153
+ };
154
+ }
155
+ catch (err) {
156
+ if (timedOut) {
157
+ return {
158
+ error: 'timeout',
159
+ message: `${url} did not answer before the caller's deadline (${frame.deadlineAt})`,
160
+ durationMs: elapsed(),
161
+ };
162
+ }
163
+ if (signal.aborted) {
164
+ return { error: 'interrupted', message: 'the relay was stopped', durationMs: elapsed() };
165
+ }
166
+ return {
167
+ error: 'unreachable',
168
+ message: `${url} unreachable: ${err instanceof Error ? err.message : String(err)}`,
169
+ durationMs: elapsed(),
170
+ };
171
+ }
172
+ finally {
173
+ clearTimeout(timer);
174
+ signal.removeEventListener('abort', onAbort);
175
+ }
176
+ }
177
+ /**
178
+ * Forward one frame to localhost and build the answer. Never throws: every
179
+ * failure is an answer, because the server is holding a visitor's request
180
+ * open until it gets one.
181
+ */
182
+ export async function answerFrame(opts, deps, frame, signal) {
183
+ const answer = await answerFrameUnclamped(opts, deps, frame, signal);
184
+ return 'error' in answer && answer.message.length > MAX_ERROR_MESSAGE_CHARS
185
+ ? { ...answer, message: `${answer.message.slice(0, MAX_ERROR_MESSAGE_CHARS - 1)}…` }
186
+ : answer;
187
+ }
188
+ /** A promise that settles to `null` on abort, so Ctrl-C does not wait out a long-poll. */
189
+ function raceAbort(p, signal) {
190
+ if (signal.aborted) {
191
+ p.catch(() => undefined);
192
+ return Promise.resolve(null);
193
+ }
194
+ return new Promise((resolve, reject) => {
195
+ const onAbort = () => {
196
+ resolve(null);
197
+ };
198
+ signal.addEventListener('abort', onAbort, { once: true });
199
+ p.then((v) => {
200
+ signal.removeEventListener('abort', onAbort);
201
+ resolve(v);
202
+ }, (e) => {
203
+ signal.removeEventListener('abort', onAbort);
204
+ reject(e instanceof Error ? e : new Error(String(e)));
205
+ });
206
+ });
207
+ }
208
+ /** `relay_ended_<reason>` → `<reason>`, per RELAY-SERVER-SPEC.md §4. */
209
+ function endReasonFromCode(code) {
210
+ return code !== null && code.startsWith('relay_ended_')
211
+ ? code.slice('relay_ended_'.length)
212
+ : 'ended';
213
+ }
214
+ /**
215
+ * The 404 a server without this feature answers, as a sentence.
216
+ *
217
+ * Exported for the spec. A CLI released ahead of the API deploy gets this, so
218
+ * it has to say plainly that the route is missing rather than that the user
219
+ * typed something wrong.
220
+ */
221
+ export function explainMissingServer(err) {
222
+ /*
223
+ * 404 can only mean the ROUTE: the spec answers an unknown target id with
224
+ * 422 `relay_target_not_found`, precisely so this branch cannot mistake a
225
+ * typo'd id for a missing feature (RELAY-SERVER-SPEC.md §3.1).
226
+ */
227
+ if (err instanceof ChatlyApiError && (err.status === 404 || err.status === 405)) {
228
+ return new Error('This Chatly API has no relay route (POST /v1/relay-sessions answered ' +
229
+ `${String(err.status)}). This API has not been deployed with the relay yet, or ` +
230
+ 'this CLI is newer than the API it talks to. Tool calls, card fetches and canvas steps still go to their configured ' +
231
+ 'URLs — nothing was redirected.');
232
+ }
233
+ return null;
234
+ }
235
+ function attachLabel() {
236
+ // `userInfo()` throws when the uid has no passwd entry (some containers).
237
+ let user = 'unknown';
238
+ try {
239
+ user = userInfo().username;
240
+ }
241
+ catch {
242
+ user = 'unknown';
243
+ }
244
+ return `chatly-cli ${user}@${hostname()}`;
245
+ }
246
+ function readSession(value) {
247
+ const r = (typeof value === 'object' && value !== null ? value : {});
248
+ if (typeof r['id'] !== 'string') {
249
+ throw new Error('POST /v1/relay-sessions answered without a session id; refusing to poll.');
250
+ }
251
+ const by = (typeof r['attachedBy'] === 'object' && r['attachedBy'] !== null ? r['attachedBy'] : {});
252
+ const who = typeof by['displayName'] === 'string' ? by['displayName'] : 'this API key';
253
+ return { id: r['id'], attachedBy: who };
254
+ }
255
+ function banner(opts, session, deps) {
256
+ deps.out(`Relay ${session.id} attached as ${session.attachedBy}.`);
257
+ deps.out('PRODUCTION calls for these targets now come to this terminal instead of their URLs:');
258
+ for (const id of opts.tools) {
259
+ deps.out(` tool ${id}`);
260
+ }
261
+ for (const id of opts.cards) {
262
+ deps.out(` card ${id}`);
263
+ }
264
+ for (const id of opts.canvases) {
265
+ deps.out(` canvas ${id} (channel)`);
266
+ }
267
+ deps.out(`Forwarding to ${opts.forwardTo} (each call's own path is appended).`);
268
+ const unsigned = [...opts.tools, ...opts.cards, ...opts.canvases].filter((id) => secretFor(opts, id) === null);
269
+ if (unsigned.length > 0) {
270
+ deps.out(`UNSIGNED for ${unsigned.join(', ')}: no --secret, so your handler's verifier will ` +
271
+ 'reject those calls and the visitor will see the failure.');
272
+ }
273
+ deps.out('The dashboard shows a banner while this is attached. Detaches after 10 minutes with no ' +
274
+ 'call, or on Ctrl-C.\n');
275
+ }
276
+ /**
277
+ * The relay loop. Exported for the spec, bounded by `deps.maxPolls` there.
278
+ *
279
+ * Frames are answered CONCURRENTLY: each one has its own caller waiting with
280
+ * its own deadline, and a slow tool call must not make a card fetch behind it
281
+ * time out.
282
+ */
283
+ export async function relay(chatly, opts, deps) {
284
+ let created;
285
+ try {
286
+ created = await chatly.request('POST', '/v1/relay-sessions', {
287
+ body: {
288
+ protocol: RELAY_PROTOCOL_VERSION,
289
+ targets: { tools: opts.tools, customerCards: opts.cards, canvasChannels: opts.canvases },
290
+ idleTimeoutSeconds: RELAY_IDLE_TIMEOUT_MS / 1000,
291
+ label: attachLabel(),
292
+ },
293
+ });
294
+ }
295
+ catch (err) {
296
+ throw explainMissingServer(err) ?? err;
297
+ }
298
+ const session = readSession(created);
299
+ banner(opts, session, deps);
300
+ const base = `/v1/relay-sessions/${encodeURIComponent(session.id)}`;
301
+ const forwards = new AbortController();
302
+ const stopForwards = () => {
303
+ forwards.abort();
304
+ };
305
+ deps.signal.addEventListener('abort', stopForwards, { once: true });
306
+ const inflight = new Set();
307
+ let frames = 0;
308
+ let answered = 0;
309
+ let failed = 0;
310
+ let polls = 0;
311
+ let failures = 0;
312
+ let lastActivity = deps.now();
313
+ let endReason = 'interrupted';
314
+ let endedByServer = false;
315
+ const handle = async (frame) => {
316
+ const answer = await answerFrame(opts, deps, frame, forwards.signal);
317
+ const line = 'error' in answer
318
+ ? ` ↳ ${answer.error}: ${answer.message}`
319
+ : ` ↳ ${String(answer.status)} in ${String(answer.durationMs)}ms`;
320
+ if ('error' in answer) {
321
+ failed += 1;
322
+ deps.err(`${line}\n`);
323
+ }
324
+ else {
325
+ // A 4xx/5xx from the handler is still an ANSWER: it is relayed, and the
326
+ // surface treats it exactly as it treats one from the configured url.
327
+ answered += 1;
328
+ if (answer.status >= 400) {
329
+ deps.err(`${line}\n`);
330
+ }
331
+ else {
332
+ deps.out(line);
333
+ }
334
+ }
335
+ try {
336
+ await chatly.request('POST', `${base}/frames/${encodeURIComponent(frame.id)}/response`, {
337
+ body: answer,
338
+ });
339
+ }
340
+ catch (err) {
341
+ // 410: the deadline passed or the session ended first. The caller has
342
+ // already been answered with the surface's own timeout.
343
+ const why = err instanceof ChatlyApiError ? `${String(err.status)} ${err.code ?? ''}` : String(err);
344
+ deps.err(` ↳ the answer to ${frame.id} was not accepted (${why.trim()})\n`);
345
+ }
346
+ };
347
+ try {
348
+ for (;;) {
349
+ if (deps.signal.aborted) {
350
+ endReason = 'interrupted';
351
+ break;
352
+ }
353
+ if (deps.maxPolls !== undefined && polls >= deps.maxPolls) {
354
+ endReason = 'max_polls';
355
+ break;
356
+ }
357
+ if (deps.now() - lastActivity >= RELAY_IDLE_TIMEOUT_MS) {
358
+ endReason = 'idle';
359
+ deps.out('No call for 10 minutes; detaching so this relay cannot be forgotten.');
360
+ break;
361
+ }
362
+ polls += 1;
363
+ let page;
364
+ try {
365
+ page = await raceAbort(chatly.request('GET', `${base}/frames`, {
366
+ query: { waitSeconds: RELAY_POLL_WAIT_SECONDS },
367
+ }), deps.signal);
368
+ }
369
+ catch (err) {
370
+ if (err instanceof ChatlyApiError && (err.status === 410 || err.status === 404)) {
371
+ endedByServer = true;
372
+ endReason = err.status === 404 ? 'not_found' : endReasonFromCode(err.code);
373
+ deps.err(`The server ended this relay (${endReason}).\n`);
374
+ break;
375
+ }
376
+ /*
377
+ * Network or 5xx after the SDK's own retries. Keep trying, with
378
+ * backoff: if this outlasts the server's 30s lease the session is
379
+ * torn down there and the next successful poll answers 410, which is
380
+ * the exit. The CLI does not guess the lease itself.
381
+ */
382
+ failures += 1;
383
+ deps.err(`poll failed (${err instanceof Error ? err.message : String(err)}); retrying. ` +
384
+ 'Calls for these targets fail until it recovers.\n');
385
+ await deps.sleep(Math.min(MAX_BACKOFF_MS, 500 * 2 ** (failures - 1)), deps.signal);
386
+ continue;
387
+ }
388
+ if (page === null) {
389
+ continue; // aborted mid-poll; the top of the loop sees it
390
+ }
391
+ failures = 0;
392
+ const { frames: fresh, unreadable } = readFrames(page);
393
+ if (unreadable > 0) {
394
+ deps.err(`${String(unreadable)} frame(s) this CLI could not read and could not answer — a newer ` +
395
+ 'server protocol than this CLI speaks? Upgrade @chatlychat/cli.\n');
396
+ }
397
+ for (const frame of fresh) {
398
+ frames += 1;
399
+ lastActivity = deps.now();
400
+ if (opts.json) {
401
+ printJson(frame);
402
+ }
403
+ else {
404
+ deps.out(`${frame.kind} ${frame.targetId} ${frame.method.toUpperCase()} ${frame.path} ${frame.id}`);
405
+ }
406
+ const p = handle(frame).finally(() => {
407
+ inflight.delete(p);
408
+ });
409
+ inflight.add(p);
410
+ }
411
+ }
412
+ }
413
+ finally {
414
+ deps.signal.removeEventListener('abort', stopForwards);
415
+ /*
416
+ * In-flight forwards are NOT aborted on a graceful end (idle, the server
417
+ * ending it): each has a caller waiting with its own deadline, and an
418
+ * answer that arrives is better than an `interrupted` one. Ctrl-C aborts
419
+ * them through `stopForwards`, which is the developer asking.
420
+ */
421
+ await Promise.allSettled([...inflight]);
422
+ if (!endedByServer) {
423
+ // Best effort: the server's lease tears it down anyway if this fails.
424
+ try {
425
+ await chatly.request('DELETE', base);
426
+ }
427
+ catch {
428
+ deps.err('Could not detach cleanly; the server will drop the relay within 30s.\n');
429
+ }
430
+ }
431
+ }
432
+ return { frames, answered, failed, polls, endReason };
433
+ }
434
+ //# sourceMappingURL=listen-relay.js.map