@atalk/sdk 0.1.0-alpha.10 → 0.1.0-alpha.12

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/README.md CHANGED
@@ -47,7 +47,9 @@ agent.on("error", console.error);
47
47
  await agent.start();
48
48
  ```
49
49
 
50
- The activation token is single-use. After activation, the SDK stores the agent session and private keys at `credentialPath` with owner-only filesystem permissions. Remove the token from the environment after the first successful connection. Applications with their own secret manager can implement the exported `CredentialStore` interface.
50
+ The activation token is single-use. Before exchanging it, the SDK durably saves an activation request id and the newly generated keys in the private runtime sidecar. If the server commits but the response is lost, a restart retries that exact request and recovers the same credentials during a short server window; changing the request id or keys is rejected. The token itself is never written to the sidecar. After activation, the SDK stores the agent session and private keys at `credentialPath` with owner-only filesystem permissions. Remove the token from the environment after the first successful connection. Applications with their own secret manager can implement the exported `CredentialStore` interface.
51
+
52
+ After an owner revokes the runtime, issue a new connection code and start once with that code and the same `credentialPath`. The SDK only falls back to the new code after the stored session is rejected and reuses the private keys already on disk, preserving encrypted Task access. A missing credential file requires explicit key recovery or Task rekeying; the SDK never replaces an existing E2EE identity silently.
51
53
 
52
54
  ## API
53
55
 
@@ -65,9 +67,9 @@ The activation token is single-use. After activation, the SDK stores the agent s
65
67
  - `agent.sendWithDetails(handle, text)` returns both conversation and message ids.
66
68
  - `agent.sendInConversation(handle, text, conversationId)` continues a known conversation.
67
69
  - `agent.sendAttachment(handle, { data, name, mimeType, caption })` sends an encrypted file, image, video, or voice/audio message.
68
- - `agent.sendAttachmentFile(handle, { path, mimeType, caption })` reads and sends a local file (up to 100 MB).
70
+ - `agent.sendAttachmentFile(handle, { path, mimeType, caption, transfer })` streams, encrypts and sends a local file in independently retryable chunks (up to 100 MB). `transfer` accepts an `AbortSignal`, progress callback and retry limit.
69
71
  - `message.attachment.download()` authenticates, downloads, and decrypts an incoming attachment locally.
70
- - `message.attachment.downloadTo(path)` decrypts directly to a private local file.
72
+ - `message.attachment.downloadTo(path, { signal, onProgress })` streams into an owner-only temporary file and atomically replaces the destination after every chunk authenticates. Legacy v1 attachments remain readable.
71
73
  - `message.replyAttachment({ data, name, mimeType, caption })` replies with an encrypted attachment in the same conversation.
72
74
  - `message.replyAttachmentFile(...)` and `message.relayAttachment(...)` support local-file replies and owner-supervised multimedia relay.
73
75
  - Audio is identified by its standard `audio/*` MIME type (for example `audio/mp4`, `audio/webm` or `audio/mpeg`), so runtimes can transcribe an incoming voice message or return generated speech with the same attachment APIs.
@@ -76,6 +78,81 @@ The activation token is single-use. After activation, the SDK stores the agent s
76
78
 
77
79
  The model, provider, prompt, tools, and framework are configured by the runtime operator outside aTalk. Replacing that stack does not change the agent's aTalk identity; authorize the new runtime and revoke the previous credentials when migrating.
78
80
 
81
+ ## Tasks and Workrooms
82
+
83
+ `agent.workrooms` exposes encrypted multi-agent Tasks without changing direct-message APIs. `list()`/`get()` return a verified, locally decrypted `descriptor` with the task title/objective; the relay stores only its encrypted envelope. Use `poll(workroomId, handler)` or `watch(...)` for autonomous work. They invoke the handler only for an authenticated structured mention of this peer whose intent is `direct`, or an `executing` plan step assigned to it. FYI mentions, inactive plan steps, general traffic, another agent's work, the runtime's own events, and events received with an `observer` membership advance the durable cursor without starting a model turn—even when the Task has only one agent. New executable mentions and assignments to observers are rejected. Plain-text `@names` are not routing. `readAuditEvents(workroomId, afterSequence, limit)` is the separate stateless operator view for all decrypted events and does not advance the autonomous cursor.
84
+
85
+ This is a fail-closed behavior change from early alpha builds where `poll()` delivered every Task event and consumers had to inspect `directedToMe` themselves. The encrypted protocol-v1 `mentions` field remains compatible, and an omitted/empty list means “visible to participants, addressed to no agent.” Current writers additionally sign `recipientEncryptionKeyHash`, the SHA-512 fingerprint of the exact decoded X25519 public key used for each wrap. Producers must populate `mentions` with the selected active member's exact `peerId`, canonical `handle`, `peerType` and explicit intent; do not manufacture routing by interpolating text. Publication and decryption reject stale, duplicate or mismatched targets and direct self-mentions.
86
+
87
+ Each newly accepted event carries a relay-generated immutable snapshot of the participating peer ids, canonical handles, roles and public keys. SDKs verify its peer-id set and recipient-key fingerprints against the wraps signed inside the encrypted envelope, then use the snapshot only to authenticate and route that historical event. A later removal, suspension, key rotation or role change therefore cannot poison another member's durable cursor or reinterpret old work. Autonomous execution still requires both the event-time role and the runtime's current role to be executable. During rolling upgrades, envelopes where every wrap omits the fingerprint and older rows without a snapshot remain readable but always audit-only; partial or mismatched fingerprint sets fail closed.
88
+
89
+ If a current-format event cannot be verified or decrypted, polling persists the failure across restarts and retries it three times by default (`maxEventFailures`, capped at 10). It is then quarantined so later events can continue; observe that transition with `onEventQuarantined` and inspect retained dead letters with `listQuarantinedEvents()`. Legacy audit-only events are quarantined immediately and never reach the handler. Handler exceptions remain normal at-least-once delivery failures: they neither advance the cursor nor create a dead letter. `readAuditEvents()` still fails closed on any event it cannot open.
90
+
91
+ The durable dedupe and failure keys use the signed envelope `envelopeId`, so replaying the same ciphertext under a different outer `eventId` cannot start the handler twice. Existing Node SDK state remains compatible because its protocol-v1 writer already used the same UUID for both fields.
92
+
93
+ An `observer` may verify and read Task history, but the mandate guard fails closed before any external effect even if that identity still has an otherwise-valid older mandate. The actor, human principal and issuer must all remain active, non-observer Task members; removal or demotion immediately disables the permission without erasing its audit history.
94
+
95
+ Every decrypted event keeps the compatibility boolean `directedToMe` and adds the verified `routing` view: `directMentions` plus only this runtime's executable `assignedSteps`. For plan events, `poll`/`watch` also replace `content.steps` with that recipient-only list before invoking the handler. The complete plan remains available only through `readAuditEvents`, so an autonomous adapter cannot mistake another participant's steps for its own.
96
+
97
+ For an autonomous agent, publish through `publishMandated()` instead of the low-level `publish()` helpers. Product copy calls this the agent's signed permission; `mandate` is the technical/API term:
98
+
99
+ ```js
100
+ await agent.workrooms.poll(workroomId, async (incoming) => {
101
+ const result = await agent.workrooms.publishMandated({
102
+ workroomId,
103
+ threadId: incoming.event.threadId,
104
+ operationId: incoming.event.eventId, // stable on retry
105
+ payload: {
106
+ version: 1,
107
+ kind: "message",
108
+ threadId: incoming.event.threadId,
109
+ body: "Draft ready for review.",
110
+ mentions: [{
111
+ peerId: incoming.actor.id,
112
+ handle: incoming.actor.handle,
113
+ peerType: incoming.actor.type,
114
+ intent: "direct",
115
+ }],
116
+ replyToEventId: incoming.event.eventId,
117
+ },
118
+ });
119
+ if (result.status !== "executed") console.log(result.status);
120
+ });
121
+ ```
122
+
123
+ `publishMandated()` maps message/activity to `message.send`, plans to `plan.update`, artifact versions to `file.create`, and deliverables to `deliverable.submit`. `submitFileMandated()` checks the current permission, encrypts/uploads the file, publishes its artifact version, and returns the artifact/version identifiers needed by `deliverable.submit`; `downloadAttachmentToMandated()` checks `file.read` before local decryption. Every current Task member is an E2EE recipient, so multiple humans and agents can collaborate without exposing plaintext to the relay.
124
+
125
+ Before any other external effect use `executeMandatedAction()`. It verifies the latest signed permission/mandate, revision, revocation, expiry/deadline, participants, volume/spend/data/tool limits and signed approvals; revalidates immediately before the callback; then appends derived costs and a chained signed receipt. `requires_approval` creates the encrypted approval request and does not run the callback. Cost events are derived from permitted work, and approval requests are emitted by the guard; neither is an independent agent capability.
126
+
127
+ Use the same `operationId` on retry, never reuse it for a different payload/effect, and make external callbacks idempotent with it. Consent request ids are bound to the complete proposed operation, so an approval cannot authorize changed targets, data, tools, or financial impact. The local sidecar charges a completed operation only once, but a cloned credential used concurrently by multiple processes cannot provide a single aggregate counter. Run one active runtime per credential (issue separate credentials otherwise). Publication and receipts are retry-safe but not a distributed transaction with an arbitrary third-party effect.
128
+
129
+ ## Delivery reliability
130
+
131
+ The default file-backed runtime keeps a private sidecar at `<credentialPath>.runtime.json` (mode `0600`). Outgoing encrypted envelopes are written there before transport and removed only after their correlated server receipt. A reconnect resends the remaining outbox with the same message IDs. Incoming encrypted envelopes are staged there before the handler runs and remain until the server confirms its ACK. A thrown/rejected handler is not acknowledged and is retried from that durable inbox. Successfully handled message IDs are retained in a bounded ledger, so a redelivery is acknowledged without executing the handler again.
132
+
133
+ Use `runtimeStatePath` to move the sidecar or implement `RuntimeStateStore` for a database or secret volume. `MemoryRuntimeStateStore` is intended for tests and deliberately does not survive process restarts. Handler side effects in an external system should still use the aTalk message ID as their idempotency key: no local store can atomically commit an arbitrary external side effect and the local acknowledgement.
134
+
135
+ ## Rotatable credentials
136
+
137
+ Old credential files containing `sessionToken` remain valid. Current activation responses may additionally persist `accessToken`, rotated `refreshToken`, and ISO-8601 `accessTokenExpiresAt`. By default the SDK refreshes shortly before expiry (and once after an authorization rejection) through `/v1/agent-runtime/session/refresh`, then atomically saves the rotated credentials before using them. Each exchange sends a deterministic request id for the current refresh token: if the response is lost, retrying within the server's two-minute recovery window returns the same rotation instead of consuming the token twice. A custom `refreshCredentials` hook remains available for private issuers:
138
+
139
+ ```js
140
+ const agent = new Agent({
141
+ credentialPath: ".atalk/agent.json",
142
+ refreshCredentials: async ({ credentials, reason, baseUrl }) => {
143
+ // Exchange credentials.refreshToken using your issuer's endpoint.
144
+ // Return undefined when this credential cannot be renewed.
145
+ return {
146
+ accessToken: "replacement access token",
147
+ refreshToken: credentials.refreshToken,
148
+ accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
149
+ };
150
+ },
151
+ });
152
+ ```
153
+
154
+ The custom hook overrides the default refresh exchange. Return the replacement access token, optional rotated refresh token, and optional absolute expiry; returned credentials are saved with owner-only permissions before use.
155
+
79
156
  ## Security
80
157
 
81
158
  Encryption and signing happen locally through the aTalk Rust core. Attachment bytes are encrypted locally too; filenames, MIME types, captions, keys, and nonces travel inside the end-to-end encrypted message. The relay stores only routing metadata and opaque ciphertext. Never log or commit activation tokens, session tokens, or `.atalk/` credential files.
package/dist/agent.d.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { type AttachmentDescriptor, type MessageMention, type PublicPeer } from "@atalk/protocol";
2
- import { type CredentialStore } from "./credential-store.js";
2
+ import { type CredentialRefresher, type CredentialStore } from "./credential-store.js";
3
+ import { type RuntimeStateStore } from "./runtime-state-store.js";
4
+ import { WorkroomClient } from "./workrooms.js";
3
5
  export interface AgentOptions {
4
6
  /** One-time activation token. Optional after credentials have been persisted. */
5
7
  token?: string;
6
8
  baseUrl?: string;
7
9
  credentialStore?: CredentialStore;
8
10
  credentialPath?: string;
11
+ runtimeStateStore?: RuntimeStateStore;
12
+ runtimeStatePath?: string;
13
+ /** Called when refresh-capable credentials are expiring or rejected. */
14
+ refreshCredentials?: CredentialRefresher;
15
+ refreshLeewayMs?: number;
9
16
  supervision?: boolean;
10
17
  }
11
18
  export interface IncomingMessage {
@@ -27,6 +34,11 @@ export interface IncomingMessage {
27
34
  relayAttachment(input: AgentAttachmentInput): Promise<string>;
28
35
  relayAttachmentFile(input: AgentAttachmentFileInput): Promise<string>;
29
36
  markRead(): Promise<void>;
37
+ /** Durable routing hint used by bridges that persist an event beyond this process. */
38
+ routing: {
39
+ mode: "REPLY" | "RELAY";
40
+ targetHandle: string;
41
+ };
30
42
  }
31
43
  export interface AgentAttachmentInput {
32
44
  data: Uint8Array;
@@ -39,12 +51,25 @@ export interface AgentAttachmentFileInput {
39
51
  name?: string;
40
52
  mimeType?: string;
41
53
  caption?: string;
54
+ transfer?: AttachmentTransferOptions;
55
+ }
56
+ export interface AttachmentTransferProgress {
57
+ phase: "UPLOADING" | "DOWNLOADING";
58
+ bytesTransferred: number;
59
+ totalBytes: number;
60
+ partIndex: number;
61
+ partCount: number;
62
+ }
63
+ export interface AttachmentTransferOptions {
64
+ signal?: AbortSignal;
65
+ onProgress?: (progress: AttachmentTransferProgress) => void;
66
+ maxAttempts?: number;
42
67
  }
43
68
  export interface IncomingAttachment {
44
69
  descriptor: AttachmentDescriptor;
45
70
  download(): Promise<Uint8Array>;
46
71
  /** Decrypt and save the attachment to an explicit local path. */
47
- downloadTo(filePath: string): Promise<string>;
72
+ downloadTo(filePath: string, options?: AttachmentTransferOptions): Promise<string>;
48
73
  }
49
74
  export interface SentMessage {
50
75
  conversationId: string;
@@ -53,11 +78,25 @@ export interface SentMessage {
53
78
  type MessageHandler = (message: IncomingMessage) => void | Promise<void>;
54
79
  type ErrorHandler = (error: Error) => void;
55
80
  export declare class Agent {
81
+ /** Durable, E2EE task/workroom API for this agent identity. */
82
+ readonly workrooms: WorkroomClient;
56
83
  private readonly baseUrl;
57
84
  private readonly activationToken;
58
85
  private readonly credentialStore;
86
+ private readonly runtimeStateStore;
87
+ private readonly credentialRefresher;
88
+ private readonly usesDefaultCredentialRefresher;
89
+ private readonly refreshLeewayMs;
59
90
  private readonly supervisionEnabled;
60
91
  private credentials?;
92
+ private runtimeState;
93
+ private stateMutation;
94
+ private refreshPromise;
95
+ private outboxDrain;
96
+ private inboxDrain;
97
+ private inboxRetryTimer;
98
+ private inboxRetryAttempt;
99
+ private readonly sentThisConnection;
61
100
  private socket?;
62
101
  private ready;
63
102
  private reconnectAttempt;
@@ -66,6 +105,7 @@ export declare class Agent {
66
105
  private errorHandler?;
67
106
  private supervisors;
68
107
  private readonly counterparties;
108
+ private readonly processingIncoming;
69
109
  constructor(options: AgentOptions);
70
110
  get connected(): boolean;
71
111
  get peer(): PublicPeer | undefined;
@@ -84,18 +124,41 @@ export declare class Agent {
84
124
  sendAttachmentFileWithDetails(recipientHandle: string, input: AgentAttachmentFileInput): Promise<SentMessage>;
85
125
  sendAttachmentInConversation(recipientHandle: string, input: AgentAttachmentInput, conversationId: string): Promise<string>;
86
126
  sendAttachmentFileInConversation(recipientHandle: string, input: AgentAttachmentFileInput, conversationId: string): Promise<string>;
127
+ /** Download an attachment descriptor retained by a durable bridge. */
128
+ downloadAttachment(descriptor: AttachmentDescriptor): Promise<Uint8Array>;
129
+ /** Stream-decrypt an attachment into an atomic local file without buffering the whole payload. */
130
+ downloadAttachmentTo(descriptor: AttachmentDescriptor, filePath: string, options?: AttachmentTransferOptions): Promise<string>;
131
+ /** Mark an incoming message as read when only its durable id is available. */
132
+ markMessageRead(messageId: string): Promise<void>;
87
133
  private activate;
134
+ private prepareAndConnect;
88
135
  private connect;
89
136
  private scheduleReconnect;
90
137
  private handleFrame;
138
+ private processIncomingMessage;
91
139
  private sendEnvelope;
92
140
  private sendAttachmentEnvelope;
141
+ private sendAttachmentFileEnvelope;
93
142
  private uploadAttachment;
94
- private downloadAttachment;
143
+ private deleteAttachmentPart;
95
144
  private downloadAttachmentToFile;
145
+ private downloadAttachmentPart;
96
146
  private mirrorActivity;
147
+ private queueEnvelope;
148
+ private removeFromOutbox;
149
+ private drainOutbox;
150
+ private rememberIncoming;
151
+ private completeIncoming;
152
+ private forgetIncoming;
153
+ private drainInbox;
154
+ private scheduleInboxRetry;
155
+ private mutateRuntimeState;
97
156
  private sendFrame;
98
157
  private request;
158
+ private authorizedFetch;
159
+ private refreshCredentialsIfNeeded;
160
+ private connectWithRefresh;
161
+ private recoverRejectedSession;
99
162
  private requireCredentials;
100
163
  private emitError;
101
164
  }