@hosty-sdk/app 0.15.0 → 0.16.2

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
@@ -180,3 +180,28 @@ an administrator browser login issued on a dedicated Core hostname, separate fro
180
180
  hosts. Existing sessions need a fresh login. Requests expire after 15 minutes and Core restart
181
181
  invalidates them. Closing your UI does not cancel a confirmed operation; status is the authority.
182
182
  Never automatically retry an execution after a lost response.
183
+
184
+ ## Assistant handoffs
185
+
186
+ `@hosty-sdk/app/assistant` exposes the version-1 client. Discover the confirmed assistant's
187
+ interface URL, version and capabilities through Core. Keep request and attachment identities
188
+ until the operation is resolved; do not create a new ID after an uncertain response.
189
+
190
+ ```ts
191
+ import { AssistantClient, createAssistantRequestId } from "@hosty-sdk/app/assistant";
192
+ const client = new AssistantClient(interfaceUrl, { version: 1, capabilities: ["attachments"] }, issueToken);
193
+ const requestId = createAssistantRequestId(); // persist with the intent before the first request
194
+ const handoff = await client.prepare({ requestId, prompt: "Inspect this", appIds: [appId] });
195
+ // Optional: client.upload(handoff.handoffId, stableAttachmentId, file, file.name, { signal })
196
+ const finalized = await client.finalize(handoff.handoffId, []);
197
+ // Validate result.open with resolveAssistantDestination against Core's declared UI surfaces.
198
+ // Opening never submits the prompt again. client.status/cancel inspect or cancel preparations.
199
+ ```
200
+
201
+ The receiver owns draft/immediate-start policy. `askAssistant(text)` remains the small embedded-app
202
+ message helper; Shell turns that message into a handoff using its verified mounted app ID. Its boolean
203
+ result reports message delivery to the embedder, not execution. Files use the separate `attachments`
204
+ capability and raw upload API. See [the complete contract](../../docs/features/hosty-harness-rename/feature.md).
205
+
206
+ Assistant control requests use a 60-second deadline. Uploads accept an optional caller-owned
207
+ `AbortSignal` and have no SDK-imposed deadline, so slow valid transfers are not aborted after a minute.
@@ -0,0 +1,67 @@
1
+ /** Version 1 of the manifest-discovered assistant handoff. */
2
+ export type AssistantContract = {
3
+ version?: number | null;
4
+ capabilities?: readonly string[] | null;
5
+ };
6
+ export type AssistantDestination = {
7
+ endpoint: string;
8
+ path: string;
9
+ };
10
+ export type AssistantHandoffResult = {
11
+ conversationId: string;
12
+ disposition: "draft" | "accepted";
13
+ open: AssistantDestination;
14
+ dispatchId?: string;
15
+ };
16
+ export type AssistantHandoff = {
17
+ handoffId: string;
18
+ requestId: string;
19
+ conversationId: string;
20
+ state: "pending" | "finalized" | "cancelled" | "expired";
21
+ createdAt: string;
22
+ expiresAt: string;
23
+ replayUntil: string;
24
+ attachments: Array<{
25
+ attachmentId: string;
26
+ name: string;
27
+ mediaType: string;
28
+ sizeBytes: number;
29
+ sha256: string;
30
+ }>;
31
+ limits?: {
32
+ maxFileBytes: number;
33
+ maxFiles: number;
34
+ maxTotalBytes: number;
35
+ };
36
+ result?: AssistantHandoffResult;
37
+ executionState?: "queued" | "running" | "completed" | "failed" | "unknown";
38
+ };
39
+ export declare function assistantContractError(contract: AssistantContract): string | null;
40
+ /** Time-bearing request identities let a provider reject retries after replay records expire. */
41
+ export declare function createAssistantRequestId(now?: number): string;
42
+ /** Resolve only inside a declared UI surface on the selected app's resolved endpoint. */
43
+ export declare function resolveAssistantDestination(destination: AssistantDestination, surfaces: readonly {
44
+ endpoint?: string | null;
45
+ path?: string | null;
46
+ url?: string | null;
47
+ }[]): string;
48
+ export declare class AssistantClient {
49
+ private readonly baseUrl;
50
+ private readonly contract;
51
+ private readonly token;
52
+ constructor(baseUrl: string, contract: AssistantContract, token: (refresh: boolean) => Promise<{
53
+ token: string;
54
+ }>);
55
+ private request;
56
+ prepare(input: {
57
+ requestId: string;
58
+ prompt: string;
59
+ appIds: string[];
60
+ }): Promise<AssistantHandoff>;
61
+ upload(handoffId: string, attachmentId: string, file: Blob, name: string, options?: {
62
+ signal?: AbortSignal;
63
+ }): Promise<AssistantHandoff["attachments"][number]>;
64
+ finalize(handoffId: string, attachmentIds: string[]): Promise<AssistantHandoff>;
65
+ status(handoffId: string): Promise<AssistantHandoff>;
66
+ cancel(handoffId: string): Promise<AssistantHandoff>;
67
+ }
@@ -0,0 +1,79 @@
1
+ export function assistantContractError(contract) {
2
+ return contract.version !== 1 || !Array.isArray(contract.capabilities)
3
+ ? "This assistant does not support the required assistant interface version 1." : null;
4
+ }
5
+ /** Time-bearing request identities let a provider reject retries after replay records expire. */
6
+ export function createAssistantRequestId(now = Date.now()) {
7
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
8
+ let time = now;
9
+ for (let i = 5; i >= 0; i--) {
10
+ bytes[i] = time % 256;
11
+ time = Math.floor(time / 256);
12
+ }
13
+ bytes[6] = (bytes[6] & 15) | 112;
14
+ bytes[8] = (bytes[8] & 63) | 128;
15
+ const hex = Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
16
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
17
+ }
18
+ /** Resolve only inside a declared UI surface on the selected app's resolved endpoint. */
19
+ export function resolveAssistantDestination(destination, surfaces) {
20
+ if (!destination || typeof destination.path !== "string" || !destination.path.startsWith("/") || destination.path.startsWith("//") || /[\\\u0000-\u001f]/.test(destination.path))
21
+ throw new Error("Invalid assistant UI destination.");
22
+ if (/%(?:2f|5c|25)/i.test(destination.path.split(/[?#]/, 1)[0]))
23
+ throw new Error("Invalid assistant UI destination.");
24
+ for (const surface of surfaces) {
25
+ if (surface.endpoint !== destination.endpoint || !surface.url)
26
+ continue;
27
+ const base = new URL(surface.url);
28
+ const url = new URL(destination.path, base.origin);
29
+ const root = new URL(surface.path || "/", base.origin).pathname.replace(/\/$/, "") || "/";
30
+ if (url.origin === base.origin && (root === "/" || url.pathname === root || url.pathname.startsWith(`${root}/`)))
31
+ return url.href;
32
+ }
33
+ throw new Error("The assistant returned a destination outside its declared UI.");
34
+ }
35
+ export class AssistantClient {
36
+ baseUrl;
37
+ contract;
38
+ token;
39
+ constructor(baseUrl, contract, token) {
40
+ this.baseUrl = baseUrl;
41
+ this.contract = contract;
42
+ this.token = token;
43
+ }
44
+ async request(route, init = {}, controlDeadline = true) {
45
+ const incompatible = assistantContractError(this.contract);
46
+ if (incompatible)
47
+ throw new Error(incompatible);
48
+ for (let attempt = 0; attempt < 2; attempt++) {
49
+ const grant = await this.token(attempt > 0);
50
+ const headers = new Headers(init.headers);
51
+ headers.set("authorization", `Bearer ${grant.token}`);
52
+ if (typeof init.body === "string")
53
+ headers.set("content-type", "application/json");
54
+ const response = await fetch(`${this.baseUrl.replace(/\/$/, "")}${route}`, { ...init, headers, signal: init.signal ?? (controlDeadline ? AbortSignal.timeout(60_000) : undefined) });
55
+ if (response.status === 401 && attempt === 0)
56
+ continue;
57
+ const body = await response.json().catch(() => null);
58
+ if (!response.ok)
59
+ throw new Error(body?.message || `Assistant request failed (${response.status}).`);
60
+ return body;
61
+ }
62
+ throw new Error("Assistant authorization expired.");
63
+ }
64
+ prepare(input) {
65
+ return this.request("/handoffs", { method: "POST", body: JSON.stringify(input) });
66
+ }
67
+ upload(handoffId, attachmentId, file, name, options = {}) {
68
+ if (!this.contract.capabilities?.includes("attachments"))
69
+ throw new Error("This assistant does not support attachments.");
70
+ return this.request(`/handoffs/${encodeURIComponent(handoffId)}/attachments/${encodeURIComponent(attachmentId)}?name=${encodeURIComponent(name)}`, {
71
+ method: "PUT", body: file, signal: options.signal, headers: { "content-type": file.type || "application/octet-stream" },
72
+ }, false);
73
+ }
74
+ finalize(handoffId, attachmentIds) {
75
+ return this.request(`/handoffs/${encodeURIComponent(handoffId)}/finalize`, { method: "POST", body: JSON.stringify({ attachmentIds }) });
76
+ }
77
+ status(handoffId) { return this.request(`/handoffs/${encodeURIComponent(handoffId)}`); }
78
+ cancel(handoffId) { return this.request(`/handoffs/${encodeURIComponent(handoffId)}`, { method: "DELETE" }); }
79
+ }
package/dist/index.d.ts CHANGED
@@ -22,16 +22,10 @@ export declare function createAuthRequiredIntent(appId: string): AuthRequiredInt
22
22
  * one (Hosty Shell: the assistant gateway, which it already mints tokens for).
23
23
  */
24
24
  /**
25
- * An embedded app asking its embedder to put text in the assistant's draft.
26
- *
27
- * The embedder reveals its assistant surface and forwards the text there; the assistant fills the
28
- * **draft** and stops. Nothing is sent. That is the contract, not a UX choice: app-provided text
29
- * entering a model that holds host shell is the same trust boundary that keeps MCP providers off by
30
- * default, and an error message is exactly the shape a prompt injection arrives in. The operator
31
- * reads it, with its source shown, and decides.
32
- *
33
- * Plain text only, and no reply. A structured payload would invite apps to smuggle shape the
34
- * operator cannot read at a glance, and an answer would turn a hand-off into a channel.
25
+ * An embedded app handing text to the selected assistant through its embedder.
26
+ * The receiving assistant applies its own draft/immediate-start setting. Authentication
27
+ * and tool permissions still apply; the message does not prove prompt authorship.
28
+ * This small embedded channel carries plain text and has no execution-result reply.
35
29
  */
36
30
  export declare const ASK_ASSISTANT_TYPE = "hosty:ask-assistant";
37
31
  /**
@@ -49,7 +43,7 @@ export interface AskAssistantMessage {
49
43
  text: string;
50
44
  }
51
45
  /**
52
- * Asks the embedder to put `text` in the operator's assistant draft.
46
+ * Asks the embedder to hand `text` to the operator's selected assistant.
53
47
  *
54
48
  * Safe to call unconditionally: standalone there is no embedder to hear it, and an embedder that
55
49
  * does not offer an assistant simply ignores it. Returns whether the message could be posted at
package/dist/index.js CHANGED
@@ -25,16 +25,10 @@ export function createAuthRequiredIntent(appId) {
25
25
  * one (Hosty Shell: the assistant gateway, which it already mints tokens for).
26
26
  */
27
27
  /**
28
- * An embedded app asking its embedder to put text in the assistant's draft.
29
- *
30
- * The embedder reveals its assistant surface and forwards the text there; the assistant fills the
31
- * **draft** and stops. Nothing is sent. That is the contract, not a UX choice: app-provided text
32
- * entering a model that holds host shell is the same trust boundary that keeps MCP providers off by
33
- * default, and an error message is exactly the shape a prompt injection arrives in. The operator
34
- * reads it, with its source shown, and decides.
35
- *
36
- * Plain text only, and no reply. A structured payload would invite apps to smuggle shape the
37
- * operator cannot read at a glance, and an answer would turn a hand-off into a channel.
28
+ * An embedded app handing text to the selected assistant through its embedder.
29
+ * The receiving assistant applies its own draft/immediate-start setting. Authentication
30
+ * and tool permissions still apply; the message does not prove prompt authorship.
31
+ * This small embedded channel carries plain text and has no execution-result reply.
38
32
  */
39
33
  export const ASK_ASSISTANT_TYPE = "hosty:ask-assistant";
40
34
  /**
@@ -47,7 +41,7 @@ export const ATTENTION_TYPE = "hosty:assistant-attention";
47
41
  /** Longer than a prompt fragment is a page dumping itself into someone's draft. */
48
42
  export const ASK_ASSISTANT_MAX_CHARS = 4000;
49
43
  /**
50
- * Asks the embedder to put `text` in the operator's assistant draft.
44
+ * Asks the embedder to hand `text` to the operator's selected assistant.
51
45
  *
52
46
  * Safe to call unconditionally: standalone there is no embedder to hear it, and an embedder that
53
47
  * does not offer an assistant simply ignores it. Returns whether the message could be posted at
@@ -24,7 +24,22 @@ export function createInstallationRouteHandler(config, options = {}) {
24
24
  return Response.json({ code: "hosty_misconfigured", message: "Installation public origin is invalid." }, { status: 503 });
25
25
  }
26
26
  }
27
- if (request.method !== "GET" && (request.headers.get("origin") !== expectedOrigin ||
27
+ const origin = request.headers.get("origin");
28
+ let sameOrigin = origin === expectedOrigin;
29
+ // Next may reconstruct request.url with localhost while the browser uses a loopback IP.
30
+ // Without an explicit public origin, accept the actual Host only with the browser's
31
+ // unforgeable-by-page-script same-origin Fetch Metadata. Host/forwarded headers alone
32
+ // must never authorize a cookie-authenticated mutation.
33
+ if (!options.publicOrigin?.trim() && !sameOrigin && request.headers.get("sec-fetch-site") === "same-origin") {
34
+ try {
35
+ const browserOrigin = new URL(origin ?? "");
36
+ sameOrigin = ["http:", "https:"].includes(browserOrigin.protocol)
37
+ && browserOrigin.origin === origin
38
+ && browserOrigin.host === request.headers.get("host");
39
+ }
40
+ catch { /* Missing, opaque or malformed origins remain denied. */ }
41
+ }
42
+ if (request.method !== "GET" && (!sameOrigin ||
28
43
  !request.headers.get("content-type")?.startsWith("application/json")))
29
44
  return Response.json({ code: "origin_denied", message: "A same-origin JSON request is required." }, { status: 403 });
30
45
  const token = readAppIdentityToken(request.headers, config);
package/dist/server.js CHANGED
@@ -552,6 +552,6 @@ async function readJsonOrThrow(response) {
552
552
  }
553
553
  }
554
554
  // Delegated tokens live in their own entry ("./delegated") because that module must stay
555
- // importable from plain Node services (the ai-gateway system app) — this file's "server-only"
555
+ // importable from plain Node services (the Harness system app) — this file's "server-only"
556
556
  // import would throw there. Re-exported here so Next apps keep one import surface.
557
557
  export { validateDelegatedToken, } from "./delegated.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hosty-sdk/app",
3
- "version": "0.15.0",
3
+ "version": "0.16.2",
4
4
  "description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -48,6 +48,10 @@
48
48
  "./install/server": {
49
49
  "types": "./dist/install-server.d.ts",
50
50
  "default": "./dist/install-server.js"
51
+ },
52
+ "./assistant": {
53
+ "types": "./dist/assistant.d.ts",
54
+ "default": "./dist/assistant.js"
51
55
  }
52
56
  },
53
57
  "scripts": {
@@ -58,7 +62,7 @@
58
62
  "server-only": "^0.0.1"
59
63
  },
60
64
  "peerDependencies": {
61
- "next": "^16.3.5",
65
+ "next": "^16.3.6",
62
66
  "react": "^19.2.8"
63
67
  },
64
68
  "peerDependenciesMeta": {
@@ -70,10 +74,10 @@
70
74
  }
71
75
  },
72
76
  "devDependencies": {
73
- "@types/node": "^24.13.5",
77
+ "@types/node": "^24.19.0",
74
78
  "@types/react": "^19.3.0",
75
79
  "typescript": "^6",
76
- "vitest": "^5.0.1"
80
+ "vitest": "^5.0.2"
77
81
  },
78
82
  "repository": {
79
83
  "type": "git",