@kangentic/protocol 0.6.0 → 0.8.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/dist/capabilities/board-tools.d.ts +1 -1
- package/dist/crypto/push-envelope.d.ts +1 -1
- package/dist/events/event.d.ts +18 -0
- package/dist/events/payloads.d.ts +9 -1
- package/dist/index.cjs +81 -50
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +79 -50
- package/dist/pairing/qr.d.ts +1 -0
- package/dist/pairing/relay-address.d.ts +18 -0
- package/dist/wire/payloads.d.ts +23 -0
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* this protocol surface honest: a tool renamed or removed desktop-side
|
|
14
14
|
* fails that test instead of silently breaking phones.
|
|
15
15
|
*/
|
|
16
|
-
export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events"];
|
|
16
|
+
export declare const BOARD_TOOL_READ_NAMES: readonly ["search_tasks", "find_task", "get_current_task", "get_task_stats", "get_usage_stats", "board_summary", "list_sessions", "get_session_history", "get_column_detail", "get_handoff_context", "get_transcript", "get_session_files", "get_session_events", "get_activity_intervals"];
|
|
17
17
|
export declare const BOARD_TOOL_WRITE_NAMES: readonly ["create_task", "update_task", "delete_task", "link_pr", "remove_attachment", "update_column", "create_backlog_task", "promote_backlog", "update_backlog_item", "delete_backlog_item"];
|
|
18
18
|
export type BoardToolReadName = (typeof BOARD_TOOL_READ_NAMES)[number];
|
|
19
19
|
export type BoardToolWriteName = (typeof BOARD_TOOL_WRITE_NAMES)[number];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const PUSH_CATEGORIES: readonly ["
|
|
1
|
+
export declare const PUSH_CATEGORIES: readonly ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
|
|
2
2
|
export type PushCategory = (typeof PUSH_CATEGORIES)[number];
|
|
3
3
|
export declare function isPushCategory(value: unknown): value is PushCategory;
|
|
4
4
|
/** Reject an opened envelope whose sentAt is older than this (stale replay). */
|
package/dist/events/event.d.ts
CHANGED
|
@@ -69,6 +69,24 @@ export type ActivityEventPayload = {
|
|
|
69
69
|
| {
|
|
70
70
|
type: 'session-ended';
|
|
71
71
|
intentional: boolean;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The agent's most recent assistant message, already collapsed to a short
|
|
75
|
+
* plain-text preview, pushed whenever it changes.
|
|
76
|
+
*
|
|
77
|
+
* A phone's session list renders exactly one line per session. Deriving it
|
|
78
|
+
* client-side cost a transcript-window request per session (measured 2.3 to
|
|
79
|
+
* 34.6 KB each, to keep a string under 200 characters) plus 0.7 to 3.8
|
|
80
|
+
* seconds of desktop work per request. The desktop already resolves the
|
|
81
|
+
* transcript to compute its delta pushes, so it can carry the line for
|
|
82
|
+
* free on a feed the phone is receiving anyway.
|
|
83
|
+
*
|
|
84
|
+
* Absent from pre-0.8.0 desktops; a phone that sees none should fall back
|
|
85
|
+
* to whatever it can derive locally rather than showing nothing.
|
|
86
|
+
*/
|
|
87
|
+
| {
|
|
88
|
+
type: 'message-preview';
|
|
89
|
+
text: string;
|
|
72
90
|
};
|
|
73
91
|
export interface ActivityEvent {
|
|
74
92
|
kind: 'activity';
|
|
@@ -115,11 +115,19 @@ export interface TerminalDimensionsWire {
|
|
|
115
115
|
}
|
|
116
116
|
/** Mirrors the desktop's ActivityState. 'permission' means the agent paused for user approval (incl. AskUserQuestion / ExitPlanMode pauses). */
|
|
117
117
|
export type ActivityStateWire = 'thinking' | 'idle' | 'permission';
|
|
118
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* Mirrors the desktop's ActivityReason discriminated union. `since` (epoch ms
|
|
120
|
+
* the session first needed the user) is optional, additive-field style like
|
|
121
|
+
* `toolCallCount`/`effort` above: an older phone parsing a payload with the
|
|
122
|
+
* field missing still validates, and a desktop that has not yet learned a
|
|
123
|
+
* `since` for a phantom-state edge case can omit it.
|
|
124
|
+
*/
|
|
119
125
|
export type ActivityReasonWire = {
|
|
120
126
|
kind: 'idle';
|
|
127
|
+
since?: number;
|
|
121
128
|
} | {
|
|
122
129
|
kind: 'permission';
|
|
130
|
+
since?: number;
|
|
123
131
|
} | {
|
|
124
132
|
kind: 'tool';
|
|
125
133
|
pendingCount: number;
|
package/dist/index.cjs
CHANGED
|
@@ -32,6 +32,7 @@ __export(index_exports, {
|
|
|
32
32
|
KK_PATTERN: () => KK_PATTERN,
|
|
33
33
|
MAX_DECODED_LENGTH: () => MAX_DECODED_LENGTH,
|
|
34
34
|
MAX_FRAME_LENGTH: () => MAX_FRAME_LENGTH,
|
|
35
|
+
MAX_RELAY_ADDRESS_LENGTH: () => MAX_RELAY_ADDRESS_LENGTH,
|
|
35
36
|
PAIRING_URI_SCHEME: () => PAIRING_URI_SCHEME,
|
|
36
37
|
PROTOCOL_VERSION: () => PROTOCOL_VERSION,
|
|
37
38
|
PUSH_CATEGORIES: () => PUSH_CATEGORIES,
|
|
@@ -70,6 +71,7 @@ __export(index_exports, {
|
|
|
70
71
|
isPushCategory: () => isPushCategory,
|
|
71
72
|
isRecord: () => isRecord,
|
|
72
73
|
isRosterEntryExpired: () => isRosterEntryExpired,
|
|
74
|
+
isSecureRelayAddress: () => isSecureRelayAddress,
|
|
73
75
|
openPushEnvelope: () => openPushEnvelope,
|
|
74
76
|
parseActivityEventPayload: () => parseActivityEventPayload,
|
|
75
77
|
parseBacklogItemWire: () => parseBacklogItemWire,
|
|
@@ -5186,6 +5188,56 @@ function base64UrlDecode(encoded) {
|
|
|
5186
5188
|
return bytes;
|
|
5187
5189
|
}
|
|
5188
5190
|
|
|
5191
|
+
// src/crypto/push-envelope.ts
|
|
5192
|
+
var PUSH_CATEGORIES = ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
|
|
5193
|
+
function isPushCategory(value) {
|
|
5194
|
+
return typeof value === "string" && PUSH_CATEGORIES.includes(value);
|
|
5195
|
+
}
|
|
5196
|
+
var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
5197
|
+
var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
5198
|
+
function requireEnvelopeString(record, field) {
|
|
5199
|
+
const value = record[field];
|
|
5200
|
+
if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
|
|
5201
|
+
return value;
|
|
5202
|
+
}
|
|
5203
|
+
function parsePushEnvelopePlaintext(value) {
|
|
5204
|
+
if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
|
|
5205
|
+
if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
|
|
5206
|
+
if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
|
|
5207
|
+
throw new Error('push envelope plaintext has an invalid "sentAt"');
|
|
5208
|
+
}
|
|
5209
|
+
return {
|
|
5210
|
+
category: value.category,
|
|
5211
|
+
projectId: requireEnvelopeString(value, "projectId"),
|
|
5212
|
+
taskId: requireEnvelopeString(value, "taskId"),
|
|
5213
|
+
sessionId: requireEnvelopeString(value, "sessionId"),
|
|
5214
|
+
taskTitle: requireEnvelopeString(value, "taskTitle"),
|
|
5215
|
+
detail: requireEnvelopeString(value, "detail"),
|
|
5216
|
+
sentAt: value.sentAt
|
|
5217
|
+
};
|
|
5218
|
+
}
|
|
5219
|
+
function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
|
|
5220
|
+
const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
|
|
5221
|
+
const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
5222
|
+
const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
|
|
5223
|
+
return base64UrlEncode(concatBytes4(nonce, ciphertext));
|
|
5224
|
+
}
|
|
5225
|
+
function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
|
|
5226
|
+
const bytes = base64UrlDecode(blob);
|
|
5227
|
+
if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
|
|
5228
|
+
throw new Error("push envelope blob is too short");
|
|
5229
|
+
}
|
|
5230
|
+
const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
|
|
5231
|
+
const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
|
|
5232
|
+
const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
|
|
5233
|
+
const decoded = JSON.parse(new TextDecoder().decode(decrypted));
|
|
5234
|
+
const plaintext = parsePushEnvelopePlaintext(decoded);
|
|
5235
|
+
const now = Date.now();
|
|
5236
|
+
if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
|
|
5237
|
+
if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
|
|
5238
|
+
return plaintext;
|
|
5239
|
+
}
|
|
5240
|
+
|
|
5189
5241
|
// src/events/payloads.ts
|
|
5190
5242
|
var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
|
|
5191
5243
|
var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
|
|
@@ -5267,6 +5319,7 @@ function isActivityReasonWire(value) {
|
|
|
5267
5319
|
switch (value.kind) {
|
|
5268
5320
|
case "idle":
|
|
5269
5321
|
case "permission":
|
|
5322
|
+
return value.since === void 0 || typeof value.since === "number";
|
|
5270
5323
|
case "turn-active":
|
|
5271
5324
|
return true;
|
|
5272
5325
|
case "tool":
|
|
@@ -5495,6 +5548,10 @@ function parseReadStreamRequestPayload(payload) {
|
|
|
5495
5548
|
}
|
|
5496
5549
|
request.limit = payload.limit;
|
|
5497
5550
|
}
|
|
5551
|
+
if (payload.terminal !== void 0) {
|
|
5552
|
+
if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
|
|
5553
|
+
request.terminal = payload.terminal;
|
|
5554
|
+
}
|
|
5498
5555
|
return request;
|
|
5499
5556
|
}
|
|
5500
5557
|
var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -5651,6 +5708,12 @@ function parseRegisterPushRequestPayload(payload) {
|
|
|
5651
5708
|
}
|
|
5652
5709
|
request.platform = payload.platform;
|
|
5653
5710
|
}
|
|
5711
|
+
if (payload.categories !== void 0) {
|
|
5712
|
+
if (!Array.isArray(payload.categories) || !payload.categories.every((entry) => typeof entry === "string")) {
|
|
5713
|
+
throw new Error('register-push payload has a malformed "categories"');
|
|
5714
|
+
}
|
|
5715
|
+
request.categories = payload.categories.filter(isPushCategory);
|
|
5716
|
+
}
|
|
5654
5717
|
if (request.action === "register") {
|
|
5655
5718
|
if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
|
|
5656
5719
|
if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
|
|
@@ -5685,61 +5748,22 @@ function parseCapabilityRequestPayload(verb, payload) {
|
|
|
5685
5748
|
}
|
|
5686
5749
|
}
|
|
5687
5750
|
|
|
5688
|
-
// src/
|
|
5689
|
-
var
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
return value;
|
|
5699
|
-
}
|
|
5700
|
-
function parsePushEnvelopePlaintext(value) {
|
|
5701
|
-
if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
|
|
5702
|
-
if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
|
|
5703
|
-
if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
|
|
5704
|
-
throw new Error('push envelope plaintext has an invalid "sentAt"');
|
|
5705
|
-
}
|
|
5706
|
-
return {
|
|
5707
|
-
category: value.category,
|
|
5708
|
-
projectId: requireEnvelopeString(value, "projectId"),
|
|
5709
|
-
taskId: requireEnvelopeString(value, "taskId"),
|
|
5710
|
-
sessionId: requireEnvelopeString(value, "sessionId"),
|
|
5711
|
-
taskTitle: requireEnvelopeString(value, "taskTitle"),
|
|
5712
|
-
detail: requireEnvelopeString(value, "detail"),
|
|
5713
|
-
sentAt: value.sentAt
|
|
5714
|
-
};
|
|
5715
|
-
}
|
|
5716
|
-
function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
|
|
5717
|
-
const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
|
|
5718
|
-
const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
5719
|
-
const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
|
|
5720
|
-
return base64UrlEncode(concatBytes4(nonce, ciphertext));
|
|
5721
|
-
}
|
|
5722
|
-
function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
|
|
5723
|
-
const bytes = base64UrlDecode(blob);
|
|
5724
|
-
if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
|
|
5725
|
-
throw new Error("push envelope blob is too short");
|
|
5726
|
-
}
|
|
5727
|
-
const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
|
|
5728
|
-
const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
|
|
5729
|
-
const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
|
|
5730
|
-
const decoded = JSON.parse(new TextDecoder().decode(decrypted));
|
|
5731
|
-
const plaintext = parsePushEnvelopePlaintext(decoded);
|
|
5732
|
-
const now = Date.now();
|
|
5733
|
-
if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
|
|
5734
|
-
if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
|
|
5735
|
-
return plaintext;
|
|
5751
|
+
// src/pairing/relay-address.ts
|
|
5752
|
+
var MAX_RELAY_ADDRESS_LENGTH = 512;
|
|
5753
|
+
var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
|
|
5754
|
+
function isSecureRelayAddress(relayAddress) {
|
|
5755
|
+
if (relayAddress.startsWith("wss://")) return true;
|
|
5756
|
+
return LOOPBACK_WS_PREFIXES.some((prefix) => {
|
|
5757
|
+
if (!relayAddress.startsWith(prefix)) return false;
|
|
5758
|
+
const boundaryChar = relayAddress.charAt(prefix.length);
|
|
5759
|
+
return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
|
|
5760
|
+
});
|
|
5736
5761
|
}
|
|
5737
5762
|
|
|
5738
5763
|
// src/pairing/qr.ts
|
|
5739
5764
|
var PAIRING_URI_SCHEME = "kangentic-pair";
|
|
5740
5765
|
var PAYLOAD_VERSION = 1;
|
|
5741
5766
|
var PAIRING_TOKEN_LENGTH = 32;
|
|
5742
|
-
var MAX_RELAY_ADDRESS_LENGTH = 512;
|
|
5743
5767
|
var MAX_PAYLOAD_LENGTH = 4096;
|
|
5744
5768
|
function writeUint32(value) {
|
|
5745
5769
|
const bytes = new Uint8Array(4);
|
|
@@ -5870,7 +5894,8 @@ var BOARD_TOOL_READ_NAMES = [
|
|
|
5870
5894
|
"get_handoff_context",
|
|
5871
5895
|
"get_transcript",
|
|
5872
5896
|
"get_session_files",
|
|
5873
|
-
"get_session_events"
|
|
5897
|
+
"get_session_events",
|
|
5898
|
+
"get_activity_intervals"
|
|
5874
5899
|
];
|
|
5875
5900
|
var BOARD_TOOL_WRITE_NAMES = [
|
|
5876
5901
|
"create_task",
|
|
@@ -5920,6 +5945,10 @@ function parseActivityEventPayload(payload) {
|
|
|
5920
5945
|
if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
|
|
5921
5946
|
return { type: "session-ended", intentional: payload.intentional };
|
|
5922
5947
|
}
|
|
5948
|
+
case "message-preview": {
|
|
5949
|
+
if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
|
|
5950
|
+
return { type: "message-preview", text: payload.text };
|
|
5951
|
+
}
|
|
5923
5952
|
default:
|
|
5924
5953
|
throw new Error('activity payload has an unknown "type"');
|
|
5925
5954
|
}
|
|
@@ -5986,6 +6015,7 @@ function isBridgeEvent(value) {
|
|
|
5986
6015
|
KK_PATTERN,
|
|
5987
6016
|
MAX_DECODED_LENGTH,
|
|
5988
6017
|
MAX_FRAME_LENGTH,
|
|
6018
|
+
MAX_RELAY_ADDRESS_LENGTH,
|
|
5989
6019
|
PAIRING_URI_SCHEME,
|
|
5990
6020
|
PROTOCOL_VERSION,
|
|
5991
6021
|
PUSH_CATEGORIES,
|
|
@@ -6024,6 +6054,7 @@ function isBridgeEvent(value) {
|
|
|
6024
6054
|
isPushCategory,
|
|
6025
6055
|
isRecord,
|
|
6026
6056
|
isRosterEntryExpired,
|
|
6057
|
+
isSecureRelayAddress,
|
|
6027
6058
|
openPushEnvelope,
|
|
6028
6059
|
parseActivityEventPayload,
|
|
6029
6060
|
parseBacklogItemWire,
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { SessionFrameKind, wrapSessionFrame, unwrapSessionFrame } from './wire/s
|
|
|
15
15
|
export { parseCapabilityRequestPayload, parseReadStreamResponsePayload, parseReadBoardResponsePayload, parseReadDiffResponsePayload, parseTranscriptWindowResponsePayload, parseRegisterPushRequestPayload, type CapabilityRequestPayloadMap, type CapabilityResponsePayloadMap, type ReadStreamRequestPayload, type ReadStreamResponsePayload, type ReadStreamSessionStatusWire, type TranscriptWindowResponsePayload, type ReadBoardRequestPayload, type ReadBoardProjectSummary, type ReadBoardProjectListResponsePayload, type ReadBoardSnapshotResponsePayload, type ReadBoardResponsePayload, type ReadDiffScope, type ReadDiffRequestPayload, type ReadDiffResponsePayload, type SendUserMessageRequestPayload, type SendUserMessageResponsePayload, type MoveTaskRequestPayload, type MoveTaskResponsePayload, type AnswerPermissionPromptRequestPayload, type AnswerPermissionPromptResponsePayload, type InteractiveTerminalRequestPayload, type InteractiveTerminalResponsePayload, type BoardToolRequestPayload, type BoardToolResponsePayload, type RegisterPushRequestPayload, type RegisterPushResponsePayload, } from './wire/payloads';
|
|
16
16
|
export { PUSH_CATEGORIES, isPushCategory, parsePushEnvelopePlaintext, sealPushEnvelope, openPushEnvelope, PUSH_ENVELOPE_MAX_AGE_MS, PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS, type PushCategory, type PushEnvelopePlaintext, } from './crypto/push-envelope';
|
|
17
17
|
export { PAIRING_URI_SCHEME, encodePairingQrPayload, decodePairingQrPayload, type PairingQrPayload, } from './pairing/qr';
|
|
18
|
+
export { MAX_RELAY_ADDRESS_LENGTH, isSecureRelayAddress } from './pairing/relay-address';
|
|
18
19
|
export { signRosterEntry, verifyRosterEntry, encodeRosterEntryForSigning, isRosterEntryExpired, findRosterDevice, rosterDeviceCapabilitySet, capabilitySetToRosterCapabilities, type RosterDeviceEntry, type DeviceRoster, } from './roster/roster';
|
|
19
20
|
export { CAPABILITY_VERBS, isCapabilityVerb, capabilitySetFromArray, capabilitySetToArray, type CapabilityVerb, type CapabilitySet, } from './capabilities/verbs';
|
|
20
21
|
export { BOARD_TOOL_READ_NAMES, BOARD_TOOL_WRITE_NAMES, isBoardToolReadName, isBoardToolWriteName, type BoardToolReadName, type BoardToolWriteName, type BoardToolName, } from './capabilities/board-tools';
|
package/dist/index.mjs
CHANGED
|
@@ -5075,6 +5075,56 @@ function base64UrlDecode(encoded) {
|
|
|
5075
5075
|
return bytes;
|
|
5076
5076
|
}
|
|
5077
5077
|
|
|
5078
|
+
// src/crypto/push-envelope.ts
|
|
5079
|
+
var PUSH_CATEGORIES = ["input-required", "turn-complete", "session-failed", "plan-complete", "spawn-stalled"];
|
|
5080
|
+
function isPushCategory(value) {
|
|
5081
|
+
return typeof value === "string" && PUSH_CATEGORIES.includes(value);
|
|
5082
|
+
}
|
|
5083
|
+
var PUSH_ENVELOPE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
5084
|
+
var PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
|
|
5085
|
+
function requireEnvelopeString(record, field) {
|
|
5086
|
+
const value = record[field];
|
|
5087
|
+
if (typeof value !== "string") throw new Error(`push envelope plaintext is missing a string "${field}"`);
|
|
5088
|
+
return value;
|
|
5089
|
+
}
|
|
5090
|
+
function parsePushEnvelopePlaintext(value) {
|
|
5091
|
+
if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
|
|
5092
|
+
if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
|
|
5093
|
+
if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
|
|
5094
|
+
throw new Error('push envelope plaintext has an invalid "sentAt"');
|
|
5095
|
+
}
|
|
5096
|
+
return {
|
|
5097
|
+
category: value.category,
|
|
5098
|
+
projectId: requireEnvelopeString(value, "projectId"),
|
|
5099
|
+
taskId: requireEnvelopeString(value, "taskId"),
|
|
5100
|
+
sessionId: requireEnvelopeString(value, "sessionId"),
|
|
5101
|
+
taskTitle: requireEnvelopeString(value, "taskTitle"),
|
|
5102
|
+
detail: requireEnvelopeString(value, "detail"),
|
|
5103
|
+
sentAt: value.sentAt
|
|
5104
|
+
};
|
|
5105
|
+
}
|
|
5106
|
+
function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
|
|
5107
|
+
const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
|
|
5108
|
+
const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
5109
|
+
const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
|
|
5110
|
+
return base64UrlEncode(concatBytes4(nonce, ciphertext));
|
|
5111
|
+
}
|
|
5112
|
+
function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
|
|
5113
|
+
const bytes = base64UrlDecode(blob);
|
|
5114
|
+
if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
|
|
5115
|
+
throw new Error("push envelope blob is too short");
|
|
5116
|
+
}
|
|
5117
|
+
const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
|
|
5118
|
+
const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
|
|
5119
|
+
const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
|
|
5120
|
+
const decoded = JSON.parse(new TextDecoder().decode(decrypted));
|
|
5121
|
+
const plaintext = parsePushEnvelopePlaintext(decoded);
|
|
5122
|
+
const now = Date.now();
|
|
5123
|
+
if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
|
|
5124
|
+
if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
|
|
5125
|
+
return plaintext;
|
|
5126
|
+
}
|
|
5127
|
+
|
|
5078
5128
|
// src/events/payloads.ts
|
|
5079
5129
|
var DIFF_FILE_STATUSES = ["A", "M", "D", "R", "C", "U"];
|
|
5080
5130
|
var TRANSCRIPT_SYSTEM_SUBTYPES = ["compaction", "command", "command_output", "session_boundary"];
|
|
@@ -5156,6 +5206,7 @@ function isActivityReasonWire(value) {
|
|
|
5156
5206
|
switch (value.kind) {
|
|
5157
5207
|
case "idle":
|
|
5158
5208
|
case "permission":
|
|
5209
|
+
return value.since === void 0 || typeof value.since === "number";
|
|
5159
5210
|
case "turn-active":
|
|
5160
5211
|
return true;
|
|
5161
5212
|
case "tool":
|
|
@@ -5384,6 +5435,10 @@ function parseReadStreamRequestPayload(payload) {
|
|
|
5384
5435
|
}
|
|
5385
5436
|
request.limit = payload.limit;
|
|
5386
5437
|
}
|
|
5438
|
+
if (payload.terminal !== void 0) {
|
|
5439
|
+
if (typeof payload.terminal !== "boolean") throw new Error('read-stream payload has an invalid "terminal"');
|
|
5440
|
+
request.terminal = payload.terminal;
|
|
5441
|
+
}
|
|
5387
5442
|
return request;
|
|
5388
5443
|
}
|
|
5389
5444
|
var ACCENT_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
@@ -5540,6 +5595,12 @@ function parseRegisterPushRequestPayload(payload) {
|
|
|
5540
5595
|
}
|
|
5541
5596
|
request.platform = payload.platform;
|
|
5542
5597
|
}
|
|
5598
|
+
if (payload.categories !== void 0) {
|
|
5599
|
+
if (!Array.isArray(payload.categories) || !payload.categories.every((entry) => typeof entry === "string")) {
|
|
5600
|
+
throw new Error('register-push payload has a malformed "categories"');
|
|
5601
|
+
}
|
|
5602
|
+
request.categories = payload.categories.filter(isPushCategory);
|
|
5603
|
+
}
|
|
5543
5604
|
if (request.action === "register") {
|
|
5544
5605
|
if (request.expoPushToken === void 0) throw new Error('register-push register payload missing "expoPushToken"');
|
|
5545
5606
|
if (request.pushKeyBase64 === void 0) throw new Error('register-push register payload missing "pushKeyBase64"');
|
|
@@ -5574,61 +5635,22 @@ function parseCapabilityRequestPayload(verb, payload) {
|
|
|
5574
5635
|
}
|
|
5575
5636
|
}
|
|
5576
5637
|
|
|
5577
|
-
// src/
|
|
5578
|
-
var
|
|
5579
|
-
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
return value;
|
|
5588
|
-
}
|
|
5589
|
-
function parsePushEnvelopePlaintext(value) {
|
|
5590
|
-
if (!isRecord(value)) throw new Error("push envelope plaintext must be an object");
|
|
5591
|
-
if (!isPushCategory(value.category)) throw new Error('push envelope plaintext has an unknown "category"');
|
|
5592
|
-
if (typeof value.sentAt !== "number" || !Number.isFinite(value.sentAt)) {
|
|
5593
|
-
throw new Error('push envelope plaintext has an invalid "sentAt"');
|
|
5594
|
-
}
|
|
5595
|
-
return {
|
|
5596
|
-
category: value.category,
|
|
5597
|
-
projectId: requireEnvelopeString(value, "projectId"),
|
|
5598
|
-
taskId: requireEnvelopeString(value, "taskId"),
|
|
5599
|
-
sessionId: requireEnvelopeString(value, "sessionId"),
|
|
5600
|
-
taskTitle: requireEnvelopeString(value, "taskTitle"),
|
|
5601
|
-
detail: requireEnvelopeString(value, "detail"),
|
|
5602
|
-
sentAt: value.sentAt
|
|
5603
|
-
};
|
|
5604
|
-
}
|
|
5605
|
-
function sealPushEnvelope(pushKey, recipientStaticPublicKey, plaintext) {
|
|
5606
|
-
const nonce = randomBytes4(XCHACHA_NONCE_LENGTH);
|
|
5607
|
-
const encodedPlaintext = new TextEncoder().encode(JSON.stringify(plaintext));
|
|
5608
|
-
const ciphertext = xaeadEncrypt(pushKey, nonce, recipientStaticPublicKey, encodedPlaintext);
|
|
5609
|
-
return base64UrlEncode(concatBytes4(nonce, ciphertext));
|
|
5610
|
-
}
|
|
5611
|
-
function openPushEnvelope(pushKey, recipientStaticPublicKey, blob) {
|
|
5612
|
-
const bytes = base64UrlDecode(blob);
|
|
5613
|
-
if (bytes.length < XCHACHA_NONCE_LENGTH + AEAD_TAG_LENGTH) {
|
|
5614
|
-
throw new Error("push envelope blob is too short");
|
|
5615
|
-
}
|
|
5616
|
-
const nonce = bytes.subarray(0, XCHACHA_NONCE_LENGTH);
|
|
5617
|
-
const ciphertext = bytes.subarray(XCHACHA_NONCE_LENGTH);
|
|
5618
|
-
const decrypted = xaeadDecrypt(pushKey, nonce, recipientStaticPublicKey, ciphertext);
|
|
5619
|
-
const decoded = JSON.parse(new TextDecoder().decode(decrypted));
|
|
5620
|
-
const plaintext = parsePushEnvelopePlaintext(decoded);
|
|
5621
|
-
const now = Date.now();
|
|
5622
|
-
if (plaintext.sentAt < now - PUSH_ENVELOPE_MAX_AGE_MS) throw new Error("push envelope is stale");
|
|
5623
|
-
if (plaintext.sentAt > now + PUSH_ENVELOPE_MAX_FUTURE_SKEW_MS) throw new Error("push envelope sentAt is in the future");
|
|
5624
|
-
return plaintext;
|
|
5638
|
+
// src/pairing/relay-address.ts
|
|
5639
|
+
var MAX_RELAY_ADDRESS_LENGTH = 512;
|
|
5640
|
+
var LOOPBACK_WS_PREFIXES = ["ws://localhost", "ws://127.0.0.1", "ws://[::1]"];
|
|
5641
|
+
function isSecureRelayAddress(relayAddress) {
|
|
5642
|
+
if (relayAddress.startsWith("wss://")) return true;
|
|
5643
|
+
return LOOPBACK_WS_PREFIXES.some((prefix) => {
|
|
5644
|
+
if (!relayAddress.startsWith(prefix)) return false;
|
|
5645
|
+
const boundaryChar = relayAddress.charAt(prefix.length);
|
|
5646
|
+
return boundaryChar === "" || boundaryChar === ":" || boundaryChar === "/" || boundaryChar === "?" || boundaryChar === "#";
|
|
5647
|
+
});
|
|
5625
5648
|
}
|
|
5626
5649
|
|
|
5627
5650
|
// src/pairing/qr.ts
|
|
5628
5651
|
var PAIRING_URI_SCHEME = "kangentic-pair";
|
|
5629
5652
|
var PAYLOAD_VERSION = 1;
|
|
5630
5653
|
var PAIRING_TOKEN_LENGTH = 32;
|
|
5631
|
-
var MAX_RELAY_ADDRESS_LENGTH = 512;
|
|
5632
5654
|
var MAX_PAYLOAD_LENGTH = 4096;
|
|
5633
5655
|
function writeUint32(value) {
|
|
5634
5656
|
const bytes = new Uint8Array(4);
|
|
@@ -5759,7 +5781,8 @@ var BOARD_TOOL_READ_NAMES = [
|
|
|
5759
5781
|
"get_handoff_context",
|
|
5760
5782
|
"get_transcript",
|
|
5761
5783
|
"get_session_files",
|
|
5762
|
-
"get_session_events"
|
|
5784
|
+
"get_session_events",
|
|
5785
|
+
"get_activity_intervals"
|
|
5763
5786
|
];
|
|
5764
5787
|
var BOARD_TOOL_WRITE_NAMES = [
|
|
5765
5788
|
"create_task",
|
|
@@ -5809,6 +5832,10 @@ function parseActivityEventPayload(payload) {
|
|
|
5809
5832
|
if (typeof payload.intentional !== "boolean") throw new Error('session-ended payload is missing "intentional"');
|
|
5810
5833
|
return { type: "session-ended", intentional: payload.intentional };
|
|
5811
5834
|
}
|
|
5835
|
+
case "message-preview": {
|
|
5836
|
+
if (typeof payload.text !== "string") throw new Error('message-preview payload is missing "text"');
|
|
5837
|
+
return { type: "message-preview", text: payload.text };
|
|
5838
|
+
}
|
|
5812
5839
|
default:
|
|
5813
5840
|
throw new Error('activity payload has an unknown "type"');
|
|
5814
5841
|
}
|
|
@@ -5874,6 +5901,7 @@ export {
|
|
|
5874
5901
|
KK_PATTERN,
|
|
5875
5902
|
MAX_DECODED_LENGTH,
|
|
5876
5903
|
MAX_FRAME_LENGTH,
|
|
5904
|
+
MAX_RELAY_ADDRESS_LENGTH,
|
|
5877
5905
|
PAIRING_URI_SCHEME,
|
|
5878
5906
|
PROTOCOL_VERSION,
|
|
5879
5907
|
PUSH_CATEGORIES,
|
|
@@ -5912,6 +5940,7 @@ export {
|
|
|
5912
5940
|
isPushCategory,
|
|
5913
5941
|
isRecord,
|
|
5914
5942
|
isRosterEntryExpired,
|
|
5943
|
+
isSecureRelayAddress,
|
|
5915
5944
|
openPushEnvelope,
|
|
5916
5945
|
parseActivityEventPayload,
|
|
5917
5946
|
parseBacklogItemWire,
|
package/dist/pairing/qr.d.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay address rules shared, byte-for-byte, with the mobile app's
|
|
3
|
+
* src/pairing/qr.ts. This module imports nothing on purpose: it is
|
|
4
|
+
* deep-imported by the desktop's src/shared/relay.ts, which the RENDERER
|
|
5
|
+
* bundles, and pulling in the rest of this package (crypto/primitives ->
|
|
6
|
+
* @noble/*) would drag Noise crypto into that bundle for no reason. Keep it
|
|
7
|
+
* dependency-free.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_RELAY_ADDRESS_LENGTH = 512;
|
|
10
|
+
/**
|
|
11
|
+
* True if the phone's pairing QR scanner will accept this relay address.
|
|
12
|
+
* wss:// is always secure; ws:// is accepted only for loopback, since the
|
|
13
|
+
* pairing token doubles as the Noise PSK and is dialed verbatim as
|
|
14
|
+
* `?slot=`. Prefix-based (not hostname-based) with an explicit boundary
|
|
15
|
+
* check, so `ws://localhost.evil.com` - a hostname that merely starts with
|
|
16
|
+
* the string "localhost" - is rejected rather than treated as loopback.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isSecureRelayAddress(relayAddress: string): boolean;
|
package/dist/wire/payloads.d.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import type { CapabilityVerb } from '../capabilities/verbs';
|
|
17
17
|
import type { JsonValue } from './messages';
|
|
18
|
+
import { type PushCategory } from '../crypto/push-envelope';
|
|
18
19
|
import { type ActivityReasonWire, type ActivityStateWire, type BacklogItemWire, type BoardColumnWire, type BoardTaskWire, type DiffFileContentWire, type DiffFileListWire, type SessionUsageWire, type TerminalDimensionsWire, type TranscriptEntryWire } from '../events/payloads';
|
|
19
20
|
export interface ReadStreamRequestPayload {
|
|
20
21
|
sessionId: string;
|
|
@@ -31,6 +32,20 @@ export interface ReadStreamRequestPayload {
|
|
|
31
32
|
beforeIndex?: number;
|
|
32
33
|
/** transcript-window only: maximum entries wanted (the desktop may cap this and may return fewer). */
|
|
33
34
|
limit?: number;
|
|
35
|
+
/**
|
|
36
|
+
* subscribe only: whether this subscription wants live PTY bytes.
|
|
37
|
+
*
|
|
38
|
+
* A phone watching its session list needs activity, permission and
|
|
39
|
+
* transcript pushes, but NOT the terminal - it discards those bytes on
|
|
40
|
+
* arrival. Measured on a live board, that discard cost roughly 13MB an hour
|
|
41
|
+
* of relay traffic and mobile data for a feed showing no terminal at all.
|
|
42
|
+
*
|
|
43
|
+
* Omitted means true, so an older phone keeps the previous behaviour and an
|
|
44
|
+
* older desktop that ignores the field simply keeps sending (wasteful, not
|
|
45
|
+
* broken). Set false for a list-only subscription and re-subscribe with it
|
|
46
|
+
* true when a terminal actually opens.
|
|
47
|
+
*/
|
|
48
|
+
terminal?: boolean;
|
|
34
49
|
}
|
|
35
50
|
/**
|
|
36
51
|
* Mirrors the desktop's SessionStatus. 'suspended' is a registered-but-
|
|
@@ -207,12 +222,20 @@ export interface BoardToolResponsePayload {
|
|
|
207
222
|
* notification envelope with (see crypto/push-envelope.ts); 'unregister'
|
|
208
223
|
* carries neither. The desktop keys the registration by the requesting
|
|
209
224
|
* device's roster identity, never by anything in this payload.
|
|
225
|
+
*
|
|
226
|
+
* `categories`, when present, is the device's push preferences: the desktop
|
|
227
|
+
* filters outgoing notifications to this set before sending, so preference
|
|
228
|
+
* enforcement never depends on the receiving device (a future iOS
|
|
229
|
+
* Notification Service Extension stays preference-free). Absent means every
|
|
230
|
+
* category (the default, and what an older device that predates this field
|
|
231
|
+
* implicitly requests); an explicit empty array means none.
|
|
210
232
|
*/
|
|
211
233
|
export interface RegisterPushRequestPayload {
|
|
212
234
|
action: 'register' | 'unregister';
|
|
213
235
|
expoPushToken?: string;
|
|
214
236
|
pushKeyBase64?: string;
|
|
215
237
|
platform?: 'android' | 'ios';
|
|
238
|
+
categories?: PushCategory[];
|
|
216
239
|
}
|
|
217
240
|
export interface RegisterPushResponsePayload {
|
|
218
241
|
registered: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kangentic/protocol",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Kangentic mobile bridge wire protocol: framing, Noise-based pairing and session handshakes, signed device roster, and capability verbs shared between desktop and mobile.",
|
|
5
5
|
"main": "dist/index.cjs",
|
|
6
6
|
"module": "dist/index.mjs",
|