@ziggs-ai/api-client 0.16.0 → 0.17.1

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
@@ -90,6 +90,9 @@ await artifacts.write({
90
90
  });
91
91
 
92
92
  // Inbox loop: references since last ack (inbox → read → act → ack)
93
+ // One host owns an agent's inbox. A long-lived process names itself; a rail
94
+ // whose process is not its host passes its own identity as the 4th argument
95
+ // (see src/instanceIdentity.ts for what each one sends).
93
96
  const inbox = new InboxClient(operatorKey, agentId);
94
97
  const env = await inbox.getInbox();
95
98
  await inbox.ack([{ kind: 'chat', id: '<chatId>', upTo: env.asOf }]);
@@ -2,22 +2,15 @@ import { runtimeLog } from '../shared/runtimeLog.js';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
3
  import { OPEN_AGREEMENT_TARGET, ORG_AGREEMENT_TARGET, isBroadcastTarget, partySideIds } from '../types.js';
4
4
  import { throwApiError } from '../shared/apiError.js';
5
+ import { buildCredsHeaders } from './operatorHeaders.js';
5
6
  // Lazy: read at call time so a `configureApiClient` call that lands after this
6
7
  // module is imported still takes effect. Baking it at module-load time would
7
8
  // freeze the URL before the host has configured one.
8
9
  function getAgreementBaseUrl() {
9
10
  return `${getBackendUrl()}/agreements`;
10
11
  }
11
- function buildHeaders(creds) {
12
- return {
13
- 'content-type': 'application/json',
14
- Authorization: `Bearer ${creds.operatorKey}`,
15
- 'X-Agent-Id': creds.agentId,
16
- // the wake's lane, so the backend can fence this call to the
17
- // engagement it belongs to rather than the agent's whole authority.
18
- ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
19
- };
20
- }
12
+ // Auth, agent and the wake's lane, from the one helper — see operatorHeaders.
13
+ const buildHeaders = buildCredsHeaders;
21
14
  function assertCreds(creds, op) {
22
15
  if (!creds?.operatorKey)
23
16
  throw new Error(`operatorKey is required for ${op}`);
@@ -1,16 +1,9 @@
1
1
  import { runtimeLog } from '../shared/runtimeLog.js';
2
2
  import { getBackendUrl } from '../utils/urlUtils.js';
3
3
  import { throwApiError } from '../shared/apiError.js';
4
- function buildHeaders(creds) {
5
- return {
6
- 'content-type': 'application/json',
7
- Authorization: `Bearer ${creds.operatorKey}`,
8
- 'X-Agent-Id': creds.agentId,
9
- // the wake's lane, so the backend can fence this call to the
10
- // engagement it belongs to rather than the agent's whole authority.
11
- ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
12
- };
13
- }
4
+ import { buildCredsHeaders } from './operatorHeaders.js';
5
+ // Auth, agent and the wake's lane, from the one helper — see operatorHeaders.
6
+ const buildHeaders = buildCredsHeaders;
14
7
  function assertCreds(creds, op) {
15
8
  if (!creds?.operatorKey)
16
9
  throw new Error(`operatorKey is required for ${op}`);
@@ -1,5 +1,6 @@
1
1
  import { getBackendUrl } from '../utils/urlUtils.js';
2
2
  import { pollSurfaceError } from '../shared/rateLimit.js';
3
+ import { buildOperatorHeaders } from './operatorHeaders.js';
3
4
  export const CONTEXT_READ_TYPES = [
4
5
  'messages',
5
6
  'artifacts',
@@ -92,13 +93,7 @@ export class ContextReadClient {
92
93
  if (query.contextGrantId) {
93
94
  url.searchParams.set('contextGrantId', query.contextGrantId);
94
95
  }
95
- const headers = {
96
- Authorization: `Bearer ${this.operatorKey}`,
97
- };
98
- if (this.agentId)
99
- headers['X-Agent-Id'] = this.agentId;
100
- if (this.laneId)
101
- headers['X-Ziggs-Lane'] = this.laneId;
96
+ const headers = buildOperatorHeaders(this.operatorKey, this.agentId, undefined, this.laneId);
102
97
  if (query.contextGrantId) {
103
98
  headers['X-Context-Grant-Id'] = query.contextGrantId;
104
99
  }
@@ -131,13 +126,7 @@ export class ContextReadClient {
131
126
  if (opts.contextGrantId) {
132
127
  url.searchParams.set('contextGrantId', opts.contextGrantId);
133
128
  }
134
- const headers = {
135
- Authorization: `Bearer ${this.operatorKey}`,
136
- };
137
- if (this.agentId)
138
- headers['X-Agent-Id'] = this.agentId;
139
- if (this.laneId)
140
- headers['X-Ziggs-Lane'] = this.laneId;
129
+ const headers = buildOperatorHeaders(this.operatorKey, this.agentId, undefined, this.laneId);
141
130
  if (opts.contextGrantId) {
142
131
  headers['X-Context-Grant-Id'] = opts.contextGrantId;
143
132
  }
@@ -8,11 +8,16 @@ export declare class InboxClient {
8
8
  private readonly operatorKey;
9
9
  private readonly agentId?;
10
10
  private readonly baseUrl;
11
+ private readonly instanceId;
11
12
  /**
12
13
  * @param operatorKey Agent-scoped or fleet operator key.
13
14
  * @param agentId Required for fleet keys (sent as X-Agent-Id). Omit for agent-scoped keys.
15
+ * @param instanceId This host's stable identity, when the caller knows it
16
+ * better than the process does — see {@link hostIdentity}. A rail whose
17
+ * process is not its host (the CLI, one hosted-MCP connection among many)
18
+ * must pass its own; everything long-lived omits it.
14
19
  */
15
- constructor(operatorKey: string, agentId?: string, baseUrl?: string);
20
+ constructor(operatorKey: string, agentId?: string, baseUrl?: string, instanceId?: string);
16
21
  private headers;
17
22
  private inboxUrl;
18
23
  getInbox(opts?: InboxReadOptions): Promise<InboxEnvelope>;
@@ -1,6 +1,6 @@
1
1
  import { getBackendUrl } from '../utils/urlUtils.js';
2
2
  import { pollSurfaceError } from '../shared/rateLimit.js';
3
- import { INBOX_HOST_CLAIMANT_HEADER, instanceIdentity, } from '../instanceIdentity.js';
3
+ import { INBOX_HOST_CLAIMANT_HEADER, hostIdentity, } from '../instanceIdentity.js';
4
4
  /**
5
5
  * The doorbell, not the door: references addressed to this agent
6
6
  * since its last ack — never content. Flow: inbox → read → act → ack
@@ -10,24 +10,30 @@ export class InboxClient {
10
10
  operatorKey;
11
11
  agentId;
12
12
  baseUrl;
13
+ instanceId;
13
14
  /**
14
15
  * @param operatorKey Agent-scoped or fleet operator key.
15
16
  * @param agentId Required for fleet keys (sent as X-Agent-Id). Omit for agent-scoped keys.
17
+ * @param instanceId This host's stable identity, when the caller knows it
18
+ * better than the process does — see {@link hostIdentity}. A rail whose
19
+ * process is not its host (the CLI, one hosted-MCP connection among many)
20
+ * must pass its own; everything long-lived omits it.
16
21
  */
17
- constructor(operatorKey, agentId, baseUrl) {
22
+ constructor(operatorKey, agentId, baseUrl, instanceId) {
18
23
  if (!operatorKey)
19
24
  throw new Error('InboxClient: operatorKey is required');
20
25
  this.operatorKey = operatorKey;
21
26
  this.agentId = agentId;
22
27
  this.baseUrl = baseUrl || getBackendUrl();
28
+ this.instanceId = hostIdentity(instanceId);
23
29
  }
24
30
  headers() {
25
31
  const headers = {
26
32
  Authorization: `Bearer ${this.operatorKey}`,
27
33
  'Content-Type': 'application/json',
28
- // This process, not the API. Two fleets of the same agent share
34
+ // This host, not the API. Two fleets of the same agent share
29
35
  // one backend task; without this they both wake.
30
- [INBOX_HOST_CLAIMANT_HEADER]: instanceIdentity(),
36
+ [INBOX_HOST_CLAIMANT_HEADER]: this.instanceId,
31
37
  };
32
38
  if (this.agentId)
33
39
  headers['X-Agent-Id'] = this.agentId;
@@ -2,17 +2,10 @@
2
2
  import { shapeAgreement } from './AgreementClient.js';
3
3
  import { getBackendUrl } from '../utils/urlUtils.js';
4
4
  import { throwApiError } from '../shared/apiError.js';
5
+ import { buildCredsHeaders } from './operatorHeaders.js';
5
6
  function getMarketplaceBaseUrl() { return `${getBackendUrl()}/marketplace`; }
6
- function buildHeaders(creds) {
7
- return {
8
- 'content-type': 'application/json',
9
- Authorization: `Bearer ${creds.operatorKey}`,
10
- 'X-Agent-Id': creds.agentId,
11
- // the wake's lane, so the backend can fence this call to the
12
- // engagement it belongs to rather than the agent's whole authority.
13
- ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
14
- };
15
- }
7
+ // Auth, agent and the wake's lane, from the one helper — see operatorHeaders.
8
+ const buildHeaders = buildCredsHeaders;
16
9
  function assertCreds(creds, op) {
17
10
  if (!creds?.operatorKey)
18
11
  throw new Error(`operatorKey is required for ${op}`);
@@ -1,17 +1,10 @@
1
1
  import { getBackendUrl } from '../utils/urlUtils.js';
2
2
  import { partyActorIds } from '../types.js';
3
3
  import { throwApiError } from '../shared/apiError.js';
4
+ import { buildCredsHeaders } from './operatorHeaders.js';
4
5
  function getTaskBaseUrl() { return `${getBackendUrl()}/tasks`; }
5
- function buildHeaders(creds) {
6
- return {
7
- 'content-type': 'application/json',
8
- Authorization: `Bearer ${creds.operatorKey}`,
9
- 'X-Agent-Id': creds.agentId,
10
- // the wake's lane, so the backend can fence this call to the
11
- // engagement it belongs to rather than the agent's whole authority.
12
- ...(creds.laneId ? { 'X-Ziggs-Lane': creds.laneId } : {}),
13
- };
14
- }
6
+ // Auth, agent and the wake's lane, from the one helper — see operatorHeaders.
7
+ const buildHeaders = buildCredsHeaders;
15
8
  function assertCreds(creds, op) {
16
9
  if (!creds?.operatorKey)
17
10
  throw new Error(`operatorKey is required for ${op}`);
@@ -28,3 +28,4 @@ export type { MyOrg, OrgResolution } from './OrgsClient.js';
28
28
  export { AgentSearchClient } from './AgentSearchClient.js';
29
29
  export { TelemetryClient } from './TelemetryClient.js';
30
30
  export { InboxClient } from './InboxClient.js';
31
+ export { WAKE_LANE_HEADER, laneHeader, buildOperatorHeaders, buildCredsHeaders, } from './operatorHeaders.js';
@@ -19,3 +19,7 @@ export { fetchMyOrgs, resolveOrgSelector, fetchDelegateAccess, fetchHostedAgentA
19
19
  export { AgentSearchClient } from './AgentSearchClient.js';
20
20
  export { TelemetryClient } from './TelemetryClient.js';
21
21
  export { InboxClient } from './InboxClient.js';
22
+ // The lane header, built in one place — see operatorHeaders.ts. Exported
23
+ // because the MCP transports in agent-sdk and ziggs-mcp send it too, and a
24
+ // copy of a header nobody notices missing goes stale unseen.
25
+ export { WAKE_LANE_HEADER, laneHeader, buildOperatorHeaders, buildCredsHeaders, } from './operatorHeaders.js';
@@ -1,8 +1,21 @@
1
1
  /**
2
- * Auth headers for operator-key HTTP clients. `X-Agent-Id` is only sent when
3
- * an agentId is present — agent-scoped keys identify the agent themselves;
4
- * fleet keys must pass one.
2
+ * Auth headers for operator-key HTTP clients.
3
+ *
4
+ * ONE place builds them, because the lane is the header a caller cannot be
5
+ * trusted to remember. Omitting `X-Ziggs-Lane` never fails: the server fences
6
+ * the wake to the agent's own org instead, so a door that forgets it answers
7
+ * short lists and refuses grants the agent's customer gave it — silently, and
8
+ * only for the agents that serve someone other than their owner. A header
9
+ * whose absence is indistinguishable from a legitimate answer has to be built
10
+ * in one place, not copied into each caller.
5
11
  */
12
+ /** The header the backend's wake fence reads (`access/wake-fence.ts`). */
13
+ export declare const WAKE_LANE_HEADER = "X-Ziggs-Lane";
14
+ /**
15
+ * The lane header, or nothing. `laneId` is a chat id, or `agrn-<agreementId>`
16
+ * for a task with no origin chat.
17
+ */
18
+ export declare function laneHeader(laneId?: string): Record<string, string>;
6
19
  export declare function buildOperatorHeaders(operatorKey: string, agentId?: string, extra?: Record<string, string>,
7
20
  /**
8
21
  * the lane this call belongs to, sent as `X-Ziggs-Lane`. The
@@ -10,3 +23,9 @@ export declare function buildOperatorHeaders(operatorKey: string, agentId?: stri
10
23
  * narrows to the agent's own org, so it can never widen reach.
11
24
  */
12
25
  laneId?: string): Record<string, string>;
26
+ /** What a `Creds`-shaped client sends on a JSON call: auth, agent, lane. */
27
+ export declare function buildCredsHeaders(creds: {
28
+ operatorKey: string;
29
+ agentId: string;
30
+ laneId?: string;
31
+ }): Record<string, string>;
@@ -1,8 +1,23 @@
1
1
  /**
2
- * Auth headers for operator-key HTTP clients. `X-Agent-Id` is only sent when
3
- * an agentId is present — agent-scoped keys identify the agent themselves;
4
- * fleet keys must pass one.
2
+ * Auth headers for operator-key HTTP clients.
3
+ *
4
+ * ONE place builds them, because the lane is the header a caller cannot be
5
+ * trusted to remember. Omitting `X-Ziggs-Lane` never fails: the server fences
6
+ * the wake to the agent's own org instead, so a door that forgets it answers
7
+ * short lists and refuses grants the agent's customer gave it — silently, and
8
+ * only for the agents that serve someone other than their owner. A header
9
+ * whose absence is indistinguishable from a legitimate answer has to be built
10
+ * in one place, not copied into each caller.
5
11
  */
12
+ /** The header the backend's wake fence reads (`access/wake-fence.ts`). */
13
+ export const WAKE_LANE_HEADER = 'X-Ziggs-Lane';
14
+ /**
15
+ * The lane header, or nothing. `laneId` is a chat id, or `agrn-<agreementId>`
16
+ * for a task with no origin chat.
17
+ */
18
+ export function laneHeader(laneId) {
19
+ return laneId ? { [WAKE_LANE_HEADER]: laneId } : {};
20
+ }
6
21
  export function buildOperatorHeaders(operatorKey, agentId, extra,
7
22
  /**
8
23
  * the lane this call belongs to, sent as `X-Ziggs-Lane`. The
@@ -12,8 +27,17 @@ export function buildOperatorHeaders(operatorKey, agentId, extra,
12
27
  laneId) {
13
28
  return {
14
29
  Authorization: `Bearer ${operatorKey}`,
30
+ // `X-Agent-Id` is only sent when an agentId is present — agent-scoped keys
31
+ // identify the agent themselves; fleet keys must pass one.
15
32
  ...(agentId ? { 'X-Agent-Id': agentId } : {}),
16
- ...(laneId ? { 'X-Ziggs-Lane': laneId } : {}),
33
+ ...laneHeader(laneId),
17
34
  ...extra,
18
35
  };
19
36
  }
37
+ /** What a `Creds`-shaped client sends on a JSON call: auth, agent, lane. */
38
+ export function buildCredsHeaders(creds) {
39
+ return {
40
+ 'content-type': 'application/json',
41
+ ...buildOperatorHeaders(creds.operatorKey, creds.agentId, undefined, creds.laneId),
42
+ };
43
+ }
package/dist/index.d.ts CHANGED
@@ -14,4 +14,5 @@ export type { Creds, Task, TaskState, PlanStep, PlanStepStatus, Agreement, Agree
14
14
  export type { ProposeTerms, ProposeDirectInput, ProposeBroadcastInput, ProposeAgreementData, ClaimOptions, } from './http/AgreementClient.js';
15
15
  export { agreementLaneId, agreementIdFromLane, isAgreementLaneId, isAgreementId } from '@ziggs-ai/contracts';
16
16
  export * from './shared/operatorKey.js';
17
+ export { INBOX_HOST_CLAIMANT_HEADER, INSTANCE_IDENTITY_MAX_LENGTH, cliHostIdentity, hostIdentity, instanceIdentity, mcpConnectionIdentity, } from './instanceIdentity.js';
17
18
  export * from './utils/appUrls.js';
package/dist/index.js CHANGED
@@ -15,4 +15,5 @@ export { parseErrorMessage, parseErrorCode, throwApiError, } from './shared/apiE
15
15
  export { ApiError } from './types.js';
16
16
  export { agreementLaneId, agreementIdFromLane, isAgreementLaneId, isAgreementId } from '@ziggs-ai/contracts';
17
17
  export * from './shared/operatorKey.js';
18
+ export { INBOX_HOST_CLAIMANT_HEADER, INSTANCE_IDENTITY_MAX_LENGTH, cliHostIdentity, hostIdentity, instanceIdentity, mcpConnectionIdentity, } from './instanceIdentity.js';
18
19
  export * from './utils/appUrls.js';
@@ -1,14 +1,41 @@
1
- /**
2
- * Which process is calling GET /inbox.
3
- *
4
- * The exclusive-read claimant must be the *host* (this fleet task, a
5
- * laptop fleet, an MCP process), not the API process. Two fleets share
6
- * one backend task, so stamping the API's identity would let both wake.
7
- *
8
- * `ecs:<id>` on Fargate, `local:<host>:<pid>` otherwise. Env-derived
9
- * and memoized.
10
- */
1
+ /** The backend refuses anything longer; it truncates nothing itself. */
2
+ export declare const INSTANCE_IDENTITY_MAX_LENGTH = 64;
11
3
  export declare const INBOX_HOST_CLAIMANT_HEADER = "X-Ziggs-Instance";
4
+ /** This process: `ZIGGS_INSTANCE_ID`, else `ecs:<id>`, else `local:<host>:<pid>`. */
12
5
  export declare function instanceIdentity(): string;
13
6
  /** Test seam: forget the memoized value so a case can set different env. */
14
7
  export declare function resetInstanceIdentityForTests(): void;
8
+ /**
9
+ * The identity to send: a rail's own choice when it has one, this process
10
+ * otherwise. Every caller-supplied value goes through the same length rule, so
11
+ * no rail can put the header over the limit the backend enforces.
12
+ */
13
+ export declare function hostIdentity(preferred?: string): string;
14
+ /**
15
+ * `ziggs-cli`: one identity per machine and user.
16
+ *
17
+ * Every command is its own process, so pid would make `ziggs inbox --ack` a
18
+ * host that never read the inbox — refused, always — and a second
19
+ * `ziggs inbox` inside the lease window a second host. A person at one
20
+ * terminal is one host; consecutive commands share this.
21
+ */
22
+ export declare function cliHostIdentity(): string;
23
+ /**
24
+ * The hosted MCP server: one identity per CONNECTION, off the key it serves.
25
+ *
26
+ * Not the backend task's identity. That process serves every connected
27
+ * assistant at once, so its own identity makes them one host and changes under
28
+ * all of them on every deploy — a hosted read answering 409 for up to 330
29
+ * seconds after each restart, and replicas refusing each other's agents.
30
+ *
31
+ * The key survives both. A token refresh re-signs the same `keyId`, so an
32
+ * assistant keeps its inbox across a backend restart and across its own
33
+ * refresh, and two assistants holding different keys are two hosts, which is
34
+ * what the ownership law says they are.
35
+ *
36
+ * Undefined when the credential carries no `keyId`. Every operator token the
37
+ * backend mints has one, so this is the shape of a credential that cannot name
38
+ * a stable host at all; the caller falls back to the process default rather
39
+ * than send one value every such connection would share.
40
+ */
41
+ export declare function mcpConnectionIdentity(operatorKey: string): string | undefined;
@@ -1,27 +1,97 @@
1
+ import { decodeOperatorKeyClaims } from './shared/operatorKey.js';
1
2
  /**
2
- * Which process is calling GET /inbox.
3
+ * Which HOST is calling `GET /inbox` and `POST /inbox/ack`.
3
4
  *
4
- * The exclusive-read claimant must be the *host* (this fleet task, a
5
- * laptop fleet, an MCP process), not the API process. Two fleets share
6
- * one backend task, so stamping the API's identity would let both wake.
5
+ * `X-Ziggs-Instance` decides inbox ownership: the first host to read acquires
6
+ * an agent's whole inbox, a second host is refused with
7
+ * `409 INBOX_HOST_CONFLICT` until the owner has stopped renewing for 330
8
+ * seconds, and an ack renews an owner but never acquires. A missing or
9
+ * overlong value is `400 INBOX_HOST_REQUIRED`.
7
10
  *
8
- * `ecs:<id>` on Fargate, `local:<host>:<pid>` otherwise. Env-derived
9
- * and memoized.
11
+ * So the value has to be stable for as long as a rail is one logical host, and
12
+ * distinct between rails that are not. What each rail sends:
13
+ *
14
+ * | rail | identity |
15
+ * | -------------------------------------- | --------------------- |
16
+ * | fleet task on Fargate | `ecs:<task id>` |
17
+ * | any other long-lived process, stdio MCP included | `local:<host>:<pid>` |
18
+ * | `ziggs-cli` | `cli:<host>:<user>` |
19
+ * | the hosted MCP server, per connection | `mcp:<keyId>` |
20
+ * | anything a scheduler pins | `ZIGGS_INSTANCE_ID` |
21
+ *
22
+ * A process identity is right for a host that outlives its inbox reads and
23
+ * wrong for one that does not. `ziggs-cli` is a new process per command, so
24
+ * pid would refuse its own `--ack`; a cron that respawns
25
+ * `npx @ziggs-ai/ziggs-mcp` every minute is a new host every minute, which is
26
+ * what `ZIGGS_INSTANCE_ID` is for.
27
+ *
28
+ * `ZIGGS_INSTANCE_ID` beats the process default wherever it is read. It is NOT
29
+ * read on the hosted rail: that one process serves every connected assistant,
30
+ * and a single env var would collapse them back into the one host this exists
31
+ * to separate.
10
32
  */
11
33
  let cached = null;
12
- const MAX_LENGTH = 64;
34
+ /** The backend refuses anything longer; it truncates nothing itself. */
35
+ export const INSTANCE_IDENTITY_MAX_LENGTH = 64;
13
36
  export const INBOX_HOST_CLAIMANT_HEADER = 'X-Ziggs-Instance';
37
+ /** This process: `ZIGGS_INSTANCE_ID`, else `ecs:<id>`, else `local:<host>:<pid>`. */
14
38
  export function instanceIdentity() {
15
39
  if (cached)
16
40
  return cached;
17
- cached = resolve().slice(0, MAX_LENGTH);
41
+ cached = fit(resolve());
18
42
  return cached;
19
43
  }
20
44
  /** Test seam: forget the memoized value so a case can set different env. */
21
45
  export function resetInstanceIdentityForTests() {
22
46
  cached = null;
23
47
  }
48
+ /**
49
+ * The identity to send: a rail's own choice when it has one, this process
50
+ * otherwise. Every caller-supplied value goes through the same length rule, so
51
+ * no rail can put the header over the limit the backend enforces.
52
+ */
53
+ export function hostIdentity(preferred) {
54
+ const chosen = preferred?.trim();
55
+ return chosen ? fit(chosen) : instanceIdentity();
56
+ }
57
+ /**
58
+ * `ziggs-cli`: one identity per machine and user.
59
+ *
60
+ * Every command is its own process, so pid would make `ziggs inbox --ack` a
61
+ * host that never read the inbox — refused, always — and a second
62
+ * `ziggs inbox` inside the lease window a second host. A person at one
63
+ * terminal is one host; consecutive commands share this.
64
+ */
65
+ export function cliHostIdentity() {
66
+ const pinned = process.env.ZIGGS_INSTANCE_ID?.trim();
67
+ return fit(pinned || `cli:${hostLabel()}:${safeUsername()}`);
68
+ }
69
+ /**
70
+ * The hosted MCP server: one identity per CONNECTION, off the key it serves.
71
+ *
72
+ * Not the backend task's identity. That process serves every connected
73
+ * assistant at once, so its own identity makes them one host and changes under
74
+ * all of them on every deploy — a hosted read answering 409 for up to 330
75
+ * seconds after each restart, and replicas refusing each other's agents.
76
+ *
77
+ * The key survives both. A token refresh re-signs the same `keyId`, so an
78
+ * assistant keeps its inbox across a backend restart and across its own
79
+ * refresh, and two assistants holding different keys are two hosts, which is
80
+ * what the ownership law says they are.
81
+ *
82
+ * Undefined when the credential carries no `keyId`. Every operator token the
83
+ * backend mints has one, so this is the shape of a credential that cannot name
84
+ * a stable host at all; the caller falls back to the process default rather
85
+ * than send one value every such connection would share.
86
+ */
87
+ export function mcpConnectionIdentity(operatorKey) {
88
+ const keyId = decodeOperatorKeyClaims(operatorKey)?.keyId?.trim();
89
+ return keyId ? fit(`mcp:${keyId}`) : undefined;
90
+ }
24
91
  function resolve() {
92
+ const pinned = process.env.ZIGGS_INSTANCE_ID?.trim();
93
+ if (pinned)
94
+ return pinned;
25
95
  const metadataUri = process.env.ECS_CONTAINER_METADATA_URI_V4 ??
26
96
  process.env.ECS_CONTAINER_METADATA_URI;
27
97
  if (metadataUri) {
@@ -30,26 +100,62 @@ function resolve() {
30
100
  if (short.length >= 8)
31
101
  return `ecs:${short}`;
32
102
  }
33
- const host = process.env.HOSTNAME || safeHostname();
34
- return `local:${host}:${process.pid}`;
103
+ return `local:${hostLabel()}:${process.pid}`;
104
+ }
105
+ /**
106
+ * Keep an overlong identity unique.
107
+ *
108
+ * Plain truncation is what the backend stopped doing on purpose: two long
109
+ * values cut to one is exactly the collision the header exists to prevent. A
110
+ * readable head plus a hash of the whole stays inside the limit and stays
111
+ * distinct.
112
+ */
113
+ function fit(value) {
114
+ if (value.length <= INSTANCE_IDENTITY_MAX_LENGTH)
115
+ return value;
116
+ const head = value.slice(0, INSTANCE_IDENTITY_MAX_LENGTH - 9);
117
+ return `${head}:${fnv1a(value)}`;
118
+ }
119
+ /** FNV-1a/32 in hex. Distinguishes hosts; it is not a checksum for anything. */
120
+ function fnv1a(value) {
121
+ let hash = 0x811c9dc5;
122
+ for (let i = 0; i < value.length; i++) {
123
+ hash ^= value.charCodeAt(i);
124
+ hash = Math.imul(hash, 0x01000193);
125
+ }
126
+ return (hash >>> 0).toString(16).padStart(8, '0');
127
+ }
128
+ function hostLabel() {
129
+ return process.env.HOSTNAME || safeHostname();
35
130
  }
36
131
  function safeHostname() {
132
+ return builtinOs()?.hostname?.() || 'unknown-host';
133
+ }
134
+ function safeUsername() {
135
+ const fromEnv = process.env.USER || process.env.USERNAME;
136
+ if (fromEnv)
137
+ return fromEnv;
138
+ return (builtinOs()?.userInfo?.()
139
+ ?.username || 'unknown-user');
140
+ }
141
+ /**
142
+ * Reached for at call time, not imported at the top of the file. A static
143
+ * `node:os` specifier makes this module — and through the inbox client, the
144
+ * package entry — unresolvable to a bundler with no node builtins, which is
145
+ * every mobile bundler. Nothing here needs the host name in order to load.
146
+ *
147
+ * `getBuiltinModule` arrived in Node 20.16 / 22.3. On anything older the
148
+ * labels fall back to their `unknown-` forms, which costs a readable name on a
149
+ * dev machine and nothing in a deployed task: those resolve through the
150
+ * container metadata path above.
151
+ */
152
+ function builtinOs() {
37
153
  try {
38
- // Reached for at call time, not imported at the top of the file. A static
39
- // `node:os` specifier makes this module — and through the inbox client,
40
- // the package entry — unresolvable to a bundler with no node builtins,
41
- // which is every mobile bundler. Nothing here needs the host name in order
42
- // to load.
43
- //
44
- // `getBuiltinModule` arrived in Node 20.16 / 22.3. On anything older the
45
- // name falls back below, which costs a host label on a dev machine and
46
- // nothing in a deployed task: those resolve through the container
47
- // metadata path above.
48
- const load = process.getBuiltinModule;
49
- const os = load?.('node:os');
50
- return os?.hostname?.() || 'unknown-host';
154
+ const load = process
155
+ .getBuiltinModule;
156
+ return load?.('node:os');
51
157
  }
52
158
  catch {
53
- return 'unknown-host';
159
+ return undefined;
54
160
  }
55
161
  }
package/dist/types.d.ts CHANGED
@@ -37,6 +37,14 @@ export interface Creds {
37
37
  * agent is not in.
38
38
  */
39
39
  laneId?: string;
40
+ /**
41
+ * This host's stable identity, sent as `X-Ziggs-Instance` on inbox reads and
42
+ * acks. Set it only on a rail whose PROCESS is not its host — the CLI, one
43
+ * hosted-MCP connection among the many a backend task serves. Everything
44
+ * long-lived leaves it unset and the process names itself. See
45
+ * `src/instanceIdentity.ts` for what each rail sends.
46
+ */
47
+ instanceId?: string;
40
48
  }
41
49
  export type TaskState = 'active' | 'proposal' | 'completed' | 'failed' | 'cancelled' | 'ledger_open';
42
50
  export type PlanStepStatus = 'pending' | 'in_progress' | 'completed' | 'skipped';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ziggs-ai/api-client",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "HTTP and WebSocket client for the Ziggs backend API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",