appduct 0.13.0 → 0.15.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/README.md +55 -24
- package/dist/artifact-inspect.d.ts +38 -39
- package/dist/bin.js +106 -7
- package/dist/cli/result-types.d.ts +35 -2
- package/dist/cli/routes/daemon/index.js +90 -2
- package/dist/cli/routes/daemon/run.js +1088 -517
- package/dist/cli/routes/daemon/start.js +98 -2
- package/dist/cli/routes/daemon/status.js +99 -2
- package/dist/cli/routes/daemon/stop.js +98 -2
- package/dist/cli/routes/doctor.js +98 -3
- package/dist/cli/routes/events/index.d.ts +0 -3
- package/dist/cli/routes/events/index.js +92 -3
- package/dist/cli/routes/events/ls.d.ts +5 -0
- package/dist/cli/routes/events/ls.js +1837 -0
- package/dist/cli/routes/events/since.js +98 -2
- package/dist/cli/routes/events/tail.js +98 -2
- package/dist/cli/routes/init.js +265 -108
- package/dist/cli/routes/keygen.js +90 -2
- package/dist/cli/routes/mcp.js +243 -25
- package/dist/cli/routes/sessions/index.js +90 -2
- package/dist/cli/routes/sessions/link.js +199 -22
- package/dist/cli/routes/sessions/ls.js +98 -2
- package/dist/cli/routes/sessions/revoke.js +98 -2
- package/dist/cli/routes/tools/call.js +98 -2
- package/dist/cli/routes/tools/describe.js +98 -2
- package/dist/cli/routes/tools/index.js +90 -2
- package/dist/cli/routes/tools/ls.js +98 -2
- package/dist/client/attach-page.d.ts +39 -0
- package/dist/client/bootstrap.d.ts +17 -3
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.js +286 -112
- package/dist/commands/doctor.d.ts +3 -4
- package/dist/commands/events-ls.d.ts +21 -0
- package/dist/commands/init.d.ts +8 -2
- package/dist/commands/link.d.ts +10 -1
- package/dist/daemon/calls.d.ts +3 -1
- package/dist/daemon/config.d.ts +6 -0
- package/dist/daemon/daemon.d.ts +6 -1
- package/dist/daemon/event-bus.d.ts +10 -0
- package/dist/daemon/links.d.ts +9 -5
- package/dist/daemon/listener.d.ts +28 -1
- package/dist/daemon/registry.d.ts +20 -12
- package/dist/daemon/rpc-errors.d.ts +6 -0
- package/dist/daemon/sessions.d.ts +14 -8
- package/dist/daemon/tls.d.ts +0 -3
- package/dist/devtools-relay/attach-tab.d.ts +33 -0
- package/dist/devtools-relay/index.d.ts +8 -0
- package/dist/devtools-relay/memory-daemon-sockets.d.ts +20 -0
- package/dist/devtools-relay/memory-devtools-browser.d.ts +10 -0
- package/dist/devtools-relay/memory-page-channel.d.ts +16 -0
- package/dist/devtools-relay/node-daemon-socket.d.ts +3 -0
- package/dist/devtools-relay/node-devtools-browser.d.ts +3 -0
- package/dist/devtools-relay/ports.d.ts +48 -0
- package/dist/devtools-relay/relay.d.ts +16 -0
- package/dist/index.js +106 -7
- package/dist/link.d.ts +35 -1
- package/dist/mcp/app-tools.d.ts +19 -0
- package/dist/mcp/connect-tool.d.ts +15 -2
- package/dist/mcp/events-tool.d.ts +39 -0
- package/dist/native-scheme.d.ts +11 -7
- package/dist/scheme.d.ts +5 -1
- package/package.json +15 -5
package/dist/daemon/daemon.d.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* recorded to the audit log inline in the `tools.call` handler below.
|
|
8
8
|
*/
|
|
9
9
|
import type { Clock } from "../cli/types.js";
|
|
10
|
+
import { type ConnectDevtoolsBrowser } from "../devtools-relay/index.js";
|
|
10
11
|
import { detectAdvertisedAddress } from "./address.js";
|
|
11
12
|
import { type AppductConfig, type ConfigWarnFn } from "./config.js";
|
|
12
13
|
import { type EventBus } from "./event-bus.js";
|
|
13
|
-
import { type DaemonListener } from "./listener.js";
|
|
14
|
+
import { type DaemonListener, type WebListener } from "./listener.js";
|
|
14
15
|
import { type RpcServer } from "./rpc-server.js";
|
|
15
16
|
import { type StateDirPaths } from "./state-dir.js";
|
|
16
17
|
import { type TimerFns } from "./timers.js";
|
|
@@ -27,6 +28,8 @@ export type DaemonOptions = {
|
|
|
27
28
|
* `config.advertisedIp`. Overridable so tests can simulate a network change between two
|
|
28
29
|
* `link.create` calls without mocking `os.networkInterfaces()` process-wide. */
|
|
29
30
|
detectAddress?: () => ReturnType<typeof detectAdvertisedAddress>;
|
|
31
|
+
/** Reaches a debugging-port browser for `web.attach`; defaults to raw CDP over `ws`. */
|
|
32
|
+
connectDevtoolsBrowser?: ConnectDevtoolsBrowser;
|
|
30
33
|
};
|
|
31
34
|
export type RunningDaemon = {
|
|
32
35
|
paths: StateDirPaths;
|
|
@@ -34,6 +37,8 @@ export type RunningDaemon = {
|
|
|
34
37
|
startedAt: Date;
|
|
35
38
|
server: RpcServer;
|
|
36
39
|
listener: DaemonListener;
|
|
40
|
+
/** The plain-HTTP listener web pages connect to. */
|
|
41
|
+
webListener: WebListener;
|
|
37
42
|
/** The host certificate the listener serves, so an in-process client can trust it. */
|
|
38
43
|
tls: TlsManager;
|
|
39
44
|
eventBus: EventBus;
|
|
@@ -28,6 +28,16 @@ export type ProjectAppEventOptions = {
|
|
|
28
28
|
/** Caps the payload's JSON to this many UTF-8 bytes (issue #113); see {@link truncateToUtf8Bytes}. */
|
|
29
29
|
payloadMaxBytes?: number;
|
|
30
30
|
};
|
|
31
|
+
/** Whether `name` matches the whole-name glob `pattern` (`*` matches any run of characters,
|
|
32
|
+
* everything else literally), in time linear in the two strings' lengths.
|
|
33
|
+
*
|
|
34
|
+
* No regex and no backtracking: the daemon is single-threaded, so a match that blows up blocks
|
|
35
|
+
* every session and RPC client (issue #112 review). Compiling `*` to `.*` was exponential in the
|
|
36
|
+
* star count, and a two-pointer matcher that backtracks to the last `*` was still quadratic.
|
|
37
|
+
* Instead the pattern is split on `*`: the first piece must be a prefix, the last a suffix, and
|
|
38
|
+
* each middle piece is found in order with `indexOf`. Taking the leftmost occurrence of each
|
|
39
|
+
* middle piece is always safe, because it leaves the most room for the pieces after it. */
|
|
40
|
+
export declare const matchesNameGlob: (pattern: string, name: string) => boolean;
|
|
31
41
|
/**
|
|
32
42
|
* Whether `event` survives `options.name`'s glob, and truncates its payload to `options
|
|
33
43
|
* .payloadMaxBytes` when given — the one implementation `since()`'s drain and `daemon.ts`'s
|
package/dist/daemon/links.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Pending links are cheap and re-issuable — they are never persisted and never appear in
|
|
6
6
|
* `sessions.list`/`sessions.describe` (only claimed sessions do; see sessions.ts).
|
|
7
7
|
*/
|
|
8
|
-
import { type AgentEndpoint } from "@appduct/shared";
|
|
8
|
+
import { type AgentEndpoint, type LinkTransport } from "@appduct/shared";
|
|
9
9
|
import type { Clock } from "../cli/types.js";
|
|
10
10
|
import type { EventBus } from "./event-bus.js";
|
|
11
11
|
import type { TimerFns } from "./timers.js";
|
|
@@ -15,6 +15,8 @@ export type PendingLink = {
|
|
|
15
15
|
token: Buffer;
|
|
16
16
|
createdAt: Date;
|
|
17
17
|
expiresAt: Date;
|
|
18
|
+
/** The listener this link can be claimed on, and only that one. */
|
|
19
|
+
transport: LinkTransport;
|
|
18
20
|
};
|
|
19
21
|
export type CreatedLink = {
|
|
20
22
|
link: PendingLink;
|
|
@@ -22,8 +24,9 @@ export type CreatedLink = {
|
|
|
22
24
|
endpoint: AgentEndpoint;
|
|
23
25
|
};
|
|
24
26
|
export type PendingLinkRegistryOptions = {
|
|
25
|
-
/** Resolves the current advertised endpoint at mint time (address may
|
|
26
|
-
|
|
27
|
+
/** Resolves the current advertised endpoint of a transport at mint time (the native address may
|
|
28
|
+
* be re-detected). */
|
|
29
|
+
getEndpoint: (transport: LinkTransport) => AgentEndpoint;
|
|
27
30
|
clock: Clock;
|
|
28
31
|
timers: TimerFns;
|
|
29
32
|
eventBus: EventBus;
|
|
@@ -31,8 +34,9 @@ export type PendingLinkRegistryOptions = {
|
|
|
31
34
|
export type PendingLinkRegistry = {
|
|
32
35
|
/** `addressOverride` forces the endpoint address encoded into this one link's bootstrap payload
|
|
33
36
|
* (the emulator/simulator fast path: `127.0.0.1`), leaving the detected endpoint used by
|
|
34
|
-
* every other link unaffected.
|
|
35
|
-
|
|
37
|
+
* every other link unaffected. It does not apply to a `web` link, whose address is always the
|
|
38
|
+
* loopback one the web listener binds. */
|
|
39
|
+
create: (ttlSeconds: number, addressOverride?: string, transport?: LinkTransport) => CreatedLink;
|
|
36
40
|
/** Looks up a pending link without consuming it (used to distinguish "unknown" from "already claimed"). */
|
|
37
41
|
get: (sessionId: string) => PendingLink | undefined;
|
|
38
42
|
/** Removes and returns the link (successful claim consumes its single use). */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The pinned-TLS WebSocket listener (ARCHITECTURE.md §7).
|
|
2
|
+
* The pinned-TLS WebSocket listener and the plain-HTTP web listener (ARCHITECTURE.md §7). Both put
|
|
3
|
+
* the same thin frame-level gate in front of
|
|
3
4
|
* `SessionManager`: JSON/binary/size hygiene, the pre-claim timeout, and post-claim
|
|
4
5
|
* type/session-id routing. All state-machine decisions (claim/resume/registry/close-code choice)
|
|
5
6
|
* live in sessions.ts — this module never inspects session state itself.
|
|
@@ -14,7 +15,13 @@ import { type SessionManager } from "./sessions.js";
|
|
|
14
15
|
import type { TlsManager, TlsMaterial } from "./tls.js";
|
|
15
16
|
import { type TimerFns } from "./timers.js";
|
|
16
17
|
export type ListenerOptions = {
|
|
18
|
+
/** `0` asks the OS for a free port. */
|
|
17
19
|
port: number;
|
|
20
|
+
/** Also bind `127.0.0.1` on the same port (issue #249). macOS lets another program bind
|
|
21
|
+
* `127.0.0.1:<port>` under a wildcard listener and then routes loopback clients to it; holding
|
|
22
|
+
* that address ourselves stops it. Linux refuses that bind for everyone, us included, because
|
|
23
|
+
* the wildcard listener already covers loopback there. */
|
|
24
|
+
bindLoopback: boolean;
|
|
18
25
|
tls: TlsManager;
|
|
19
26
|
sessionManager: SessionManager;
|
|
20
27
|
preClaimTimeoutMs?: number;
|
|
@@ -31,3 +38,23 @@ export type DaemonListener = {
|
|
|
31
38
|
close: () => Promise<void>;
|
|
32
39
|
};
|
|
33
40
|
export declare const startListener: (options: ListenerOptions) => Promise<DaemonListener>;
|
|
41
|
+
export type WebListenerOptions = {
|
|
42
|
+
sessionManager: SessionManager;
|
|
43
|
+
/** Origins allowed in addition to localhost, `127.0.0.1` and `[::1]` on any port. Compared
|
|
44
|
+
* as exact strings against the `Origin` header (`config.json`'s `webOrigins`). */
|
|
45
|
+
origins: readonly string[];
|
|
46
|
+
preClaimTimeoutMs?: number;
|
|
47
|
+
timers?: TimerFns;
|
|
48
|
+
};
|
|
49
|
+
export type WebListener = {
|
|
50
|
+
port: () => number | undefined;
|
|
51
|
+
close: () => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The plain-HTTP listener web pages connect to, bound to `127.0.0.1` on an OS-assigned port. A
|
|
55
|
+
* browser cannot pin the daemon's self-signed certificate, so it cannot use the TLS listener.
|
|
56
|
+
* Instead every upgrade is checked against its `Origin` header and refused with 403 unless that
|
|
57
|
+
* is a loopback origin or listed in `origins`. A request with no `Origin` is let through: only a
|
|
58
|
+
* local non-browser process sends none, and it could connect to the daemon anyway.
|
|
59
|
+
*/
|
|
60
|
+
export declare const startWebListener: (options: WebListenerOptions) => Promise<WebListener>;
|
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-session
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Per-session registry of what an app declared, by name (ARCHITECTURE.md §7): one instance holds a
|
|
3
|
+
* session's tools, another its events (issue #124). Callers (sessions.ts) must validate incoming
|
|
4
|
+
* `tool_registry_*`/`event_registry_*` wire messages with the shared guards *before* calling into
|
|
5
|
+
* this module — v1 crashed by indexing into an unvalidated `tools: [null]` snapshot; this module
|
|
6
|
+
* only ever stores already-validated descriptors.
|
|
6
7
|
*/
|
|
7
|
-
import type { ToolDescriptor } from "@appduct/shared";
|
|
8
|
-
export type
|
|
8
|
+
import type { EventDescriptor, ToolDescriptor } from "@appduct/shared";
|
|
9
|
+
export type Registry<T extends {
|
|
10
|
+
name: string;
|
|
11
|
+
}> = {
|
|
9
12
|
/** Authoritative replace: used for the initial snapshot and every post-resume re-sync. */
|
|
10
|
-
snapshot: (
|
|
11
|
-
upsert: (
|
|
13
|
+
snapshot: (items: T[]) => void;
|
|
14
|
+
upsert: (item: T) => void;
|
|
12
15
|
remove: (name: string) => void;
|
|
13
|
-
list: () =>
|
|
14
|
-
get: (name: string) =>
|
|
16
|
+
list: () => T[];
|
|
17
|
+
get: (name: string) => T | undefined;
|
|
15
18
|
count: () => number;
|
|
16
19
|
};
|
|
17
|
-
|
|
18
|
-
export
|
|
20
|
+
export type ToolRegistry = Registry<ToolDescriptor>;
|
|
21
|
+
export type EventRegistry = Registry<EventDescriptor>;
|
|
22
|
+
/** `onChange` fires after every mutation (snapshot/upsert/remove) — used to emit `tools_changed`
|
|
23
|
+
* and `events_changed`. */
|
|
24
|
+
export declare const createRegistry: <T extends {
|
|
25
|
+
name: string;
|
|
26
|
+
}>(onChange?: () => void) => Registry<T>;
|
|
@@ -14,3 +14,9 @@ export declare class RpcApplicationError extends Error {
|
|
|
14
14
|
/** JSON-RPC error code; defaults to the shared "server error" range. */
|
|
15
15
|
code?: number, details?: unknown | undefined);
|
|
16
16
|
}
|
|
17
|
+
/** The `session_suspended` message for an app that closed its socket because it went to the
|
|
18
|
+
* background: says why and how to recover. */
|
|
19
|
+
export declare const appBackgroundedMessage: (alias: string) => string;
|
|
20
|
+
/** The `session_suspended` message for a call that was pending when the app reconnected on a new
|
|
21
|
+
* socket: the app dropped the call with the old socket, so its result is lost. */
|
|
22
|
+
export declare const appReconnectedMessage: (alias: string) => string;
|
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* §6: "terminal states free the alias" — there is no persisted history, matching §3/§13's "no
|
|
11
11
|
* per-session files, all state in-daemon-memory").
|
|
12
12
|
*/
|
|
13
|
-
import { type EventMessage, type SessionClaimMessage, type SessionResumeMessage, type SessionSummary, type ToolCallMessage, type ToolCallProgressMessage, type ToolCancelMessage, type ToolErrorMessage, type ToolResultMessage } from "@appduct/shared";
|
|
13
|
+
import { type EventMessage, type SessionClaimMessage, type SessionResumeMessage, type LinkTransport, type SessionSummary, type SessionSuspendReason, type ToolCallMessage, type ToolCallProgressMessage, type ToolCancelMessage, type ToolErrorMessage, type ToolResultMessage } from "@appduct/shared";
|
|
14
14
|
import type { WebSocket } from "ws";
|
|
15
15
|
import type { Clock } from "../cli/types.js";
|
|
16
16
|
import type { EventBus } from "./event-bus.js";
|
|
17
17
|
import { type CreatedLink } from "./links.js";
|
|
18
|
-
import { type ToolRegistry } from "./registry.js";
|
|
18
|
+
import { type EventRegistry, type ToolRegistry } from "./registry.js";
|
|
19
19
|
import { type TimerFns } from "./timers.js";
|
|
20
20
|
/** Wire message types allowed *after* claim (session_claim/session_resume are pre-claim only). */
|
|
21
21
|
export declare const POST_CLAIM_MESSAGE_TYPES: Set<string>;
|
|
@@ -26,7 +26,7 @@ export type SessionManagerOptions = {
|
|
|
26
26
|
graceSeconds: number;
|
|
27
27
|
keepaliveIntervalSeconds: number;
|
|
28
28
|
linkTtlSeconds: number;
|
|
29
|
-
getEndpoint: () => CreatedLink["endpoint"];
|
|
29
|
+
getEndpoint: (transport: LinkTransport) => CreatedLink["endpoint"];
|
|
30
30
|
eventBus: EventBus;
|
|
31
31
|
clock?: Clock;
|
|
32
32
|
timers?: TimerFns;
|
|
@@ -48,11 +48,15 @@ export type SessionManager = {
|
|
|
48
48
|
* 07's emulator/simulator fast path); see `links.ts`'s `PendingLinkRegistry.create`. Omits
|
|
49
49
|
* `LinkCreateResult.pin` — the session manager has no `TlsManager` handle, so the RPC handler
|
|
50
50
|
* (`daemon.ts`) attaches the current SPKI pin itself before returning to the caller. */
|
|
51
|
-
createLink: (ttlSeconds?: number, addressOverride?: string) => Omit<LinkCreateResult, "pin">;
|
|
52
|
-
/** First message on a fresh socket
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
createLink: (ttlSeconds?: number, addressOverride?: string, transport?: LinkTransport) => Omit<LinkCreateResult, "pin">;
|
|
52
|
+
/** First message on a fresh socket accepted on `transport`'s listener. Returns the claimed
|
|
53
|
+
* sessionId, or `null` after closing the socket. A link minted for the other transport is
|
|
54
|
+
* refused with `wrong_transport` and stays claimable on its own listener. */
|
|
55
|
+
handleClaim: (socket: WebSocket, message: SessionClaimMessage, transport: LinkTransport) => string | null;
|
|
56
|
+
/** First message on a resume socket accepted on `transport`'s listener. Returns the resumed
|
|
57
|
+
* sessionId, or `null` after closing the socket. A session claimed on the other listener is
|
|
58
|
+
* refused with `wrong_transport` before its token is checked, so its token is not rotated. */
|
|
59
|
+
handleResume: (socket: WebSocket, message: SessionResumeMessage, transport: LinkTransport) => string | null;
|
|
56
60
|
/** Dispatches an already-type-checked post-claim message; closes the socket on invalid content. */
|
|
57
61
|
handlePostClaimMessage: (sessionId: string, socket: WebSocket, message: Record<string, unknown>) => void;
|
|
58
62
|
list: () => SessionSummary[];
|
|
@@ -69,7 +73,9 @@ export type SessionManager = {
|
|
|
69
73
|
sessionId: string;
|
|
70
74
|
alias: string;
|
|
71
75
|
state: "active" | "suspended";
|
|
76
|
+
suspendReason?: SessionSuspendReason;
|
|
72
77
|
registry: ToolRegistry;
|
|
78
|
+
eventRegistry: EventRegistry;
|
|
73
79
|
};
|
|
74
80
|
/** Sends a `tool_call` frame to the session's active socket; `false` if the session has no
|
|
75
81
|
* active socket right now (caller has typically already checked ACTIVE state via
|
package/dist/daemon/tls.d.ts
CHANGED
|
@@ -23,9 +23,6 @@ export declare class HostKeyError extends Error {
|
|
|
23
23
|
* Deliberately the same shape as `ConfigWarnFn` (`config.ts`) so callers can pass the same
|
|
24
24
|
* function through without an adapter. */
|
|
25
25
|
export type TlsWarnFn = (message: string) => void;
|
|
26
|
-
/** Refuses a key file that is group/world-readable. Missing files are handled by the caller
|
|
27
|
-
* (`loadOrGenerateHostKeyPem`), which auto-generates instead of throwing. */
|
|
28
|
-
export declare const loadHostKeyPem: (keyPath: string) => Promise<string>;
|
|
29
26
|
/**
|
|
30
27
|
* Loads the host key at `keyPath`, generating one in its place if none exists yet (dev-mode
|
|
31
28
|
* zero-config bootstrap). Only the ENOENT case auto-generates: any other `stat`/`readFile` failure
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type RelayLink } from "./relay.js";
|
|
2
|
+
import type { DevtoolsBrowser, DevtoolsPage, OpenDaemonSocket } from "./ports.js";
|
|
3
|
+
export type AttachBrowserTabOptions = {
|
|
4
|
+
browser: DevtoolsBrowser;
|
|
5
|
+
/**
|
|
6
|
+
* The page relayed for each target id. Attaching a tab closes the page already relayed for it, so a
|
|
7
|
+
* tab has one relay however its browser was reached; the new page is recorded here as soon as it is open.
|
|
8
|
+
*/
|
|
9
|
+
relayedTabs: Map<string, DevtoolsPage>;
|
|
10
|
+
/** The tab's address, or the start of it. */
|
|
11
|
+
url: string;
|
|
12
|
+
/** Picks the tab directly, for when several tabs match `url`. */
|
|
13
|
+
targetId?: string;
|
|
14
|
+
/** Mints the web link for the page. Called once a tab is picked, so a failed pick strands no link. */
|
|
15
|
+
mintLink: () => Promise<RelayLink & {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
}>;
|
|
19
|
+
openDaemonSocket: OpenDaemonSocket;
|
|
20
|
+
};
|
|
21
|
+
/** `url` is the tab's own address, without the link's payload. */
|
|
22
|
+
export type AttachedTab = {
|
|
23
|
+
sessionId: string;
|
|
24
|
+
url: string;
|
|
25
|
+
targetId: string;
|
|
26
|
+
expiresAt: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Picks a tab of a debugging-port browser, relays its page to the daemon and connects it over the
|
|
30
|
+
* binding, so the session outlives this call. The page must already load `@appduct/web`. Reloading
|
|
31
|
+
* resumes the session; a popup or a navigation that creates a new target is not relayed.
|
|
32
|
+
*/
|
|
33
|
+
export declare const attachBrowserTab: (options: AttachBrowserTabOptions) => Promise<AttachedTab>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createMemoryDaemonSockets, type MemoryDaemonSocket, type MemoryDaemonSockets } from "./memory-daemon-sockets.js";
|
|
2
|
+
export { attachBrowserTab, type AttachBrowserTabOptions, type AttachedTab } from "./attach-tab.js";
|
|
3
|
+
export { createMemoryDevtoolsBrowser, type MemoryDevtoolsBrowser } from "./memory-devtools-browser.js";
|
|
4
|
+
export { createMemoryPageChannel, type MemoryPageChannel } from "./memory-page-channel.js";
|
|
5
|
+
export { connectNodeDevtoolsBrowser } from "./node-devtools-browser.js";
|
|
6
|
+
export { openNodeDaemonSocket } from "./node-daemon-socket.js";
|
|
7
|
+
export type { ConnectDevtoolsBrowser, DaemonSocket, DaemonSocketEvents, DevtoolsBrowser, DevtoolsPage, DevtoolsTarget, OpenDaemonSocket, PageChannel } from "./ports.js";
|
|
8
|
+
export { linkPayload, parseLink, relayPage, type RelayLink } from "./relay.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { OpenDaemonSocket } from "./ports.js";
|
|
2
|
+
/** One daemon socket the relay opened, driven by the test. */
|
|
3
|
+
export type MemoryDaemonSocket = {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
/** Text frames the relay sent, in order. */
|
|
6
|
+
readonly sent: string[];
|
|
7
|
+
/** How the relay closed the socket, once it did. */
|
|
8
|
+
readonly closedByRelay: {
|
|
9
|
+
code: number;
|
|
10
|
+
reason: string;
|
|
11
|
+
} | undefined;
|
|
12
|
+
open(): void;
|
|
13
|
+
receive(text: string): void;
|
|
14
|
+
closeFromDaemon(code: number, reason: string): void;
|
|
15
|
+
};
|
|
16
|
+
export type MemoryDaemonSockets = {
|
|
17
|
+
open: OpenDaemonSocket;
|
|
18
|
+
readonly sockets: MemoryDaemonSocket[];
|
|
19
|
+
};
|
|
20
|
+
export declare const createMemoryDaemonSockets: () => MemoryDaemonSockets;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DevtoolsBrowser, DevtoolsTarget } from "./ports.js";
|
|
2
|
+
import { type MemoryPageChannel } from "./memory-page-channel.js";
|
|
3
|
+
export type MemoryDevtoolsBrowser = DevtoolsBrowser & {
|
|
4
|
+
/** The tab's page, as the relay sees it: the one the next `openPage` returns, then the latest it returned. */
|
|
5
|
+
page(targetId: string): MemoryPageChannel;
|
|
6
|
+
/** Target ids passed to `openPage`, in order, that have not been closed since. */
|
|
7
|
+
attachedTargets(): string[];
|
|
8
|
+
};
|
|
9
|
+
/** A browser with the given tabs, each backed by a memory page channel. */
|
|
10
|
+
export declare const createMemoryDevtoolsBrowser: (tabs: DevtoolsTarget[]) => MemoryDevtoolsBrowser;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PageChannel } from "./ports.js";
|
|
2
|
+
export type MemoryPageChannel = PageChannel & {
|
|
3
|
+
/** The page calls its binding with an object, sent as JSON. */
|
|
4
|
+
call(message: Record<string, unknown>): void;
|
|
5
|
+
/** The document is destroyed. */
|
|
6
|
+
resetContext(): void;
|
|
7
|
+
/** The connection is dropped: the context resets once, then the page stops calling the relay. */
|
|
8
|
+
close(): void;
|
|
9
|
+
/** What the relay delivered to `__APPDUCT__.receive`, parsed. */
|
|
10
|
+
received(): Record<string, unknown>[];
|
|
11
|
+
/** Every other expression evaluated in the page, in order. */
|
|
12
|
+
evaluated(): string[];
|
|
13
|
+
/** Makes `evaluate` reject for expressions matching `pattern`. */
|
|
14
|
+
failEvaluate(pattern: RegExp, error: Error): void;
|
|
15
|
+
};
|
|
16
|
+
export declare const createMemoryPageChannel: () => MemoryPageChannel;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** What the relay needs from outside the process. Each port has a memory fake beside it. */
|
|
2
|
+
/** The page the session runs in, as the relay sees it. A Playwright `Page` is adapted to this by `attachPage`. */
|
|
3
|
+
export type PageChannel = {
|
|
4
|
+
/** Calls the page makes to its exposed binding, as the JSON string it passed. */
|
|
5
|
+
onBindingCall(handler: (json: string) => void): void;
|
|
6
|
+
/** Runs an expression in the page's current document. Rejects when there is none. */
|
|
7
|
+
evaluate(expression: string): Promise<unknown>;
|
|
8
|
+
/** A document was destroyed or replaced: a reload, a navigation to another document, or the page closing. */
|
|
9
|
+
onContextReset(handler: () => void): void;
|
|
10
|
+
};
|
|
11
|
+
export type DaemonSocketEvents = {
|
|
12
|
+
open(): void;
|
|
13
|
+
message(text: string): void;
|
|
14
|
+
close(code: number, reason: string): void;
|
|
15
|
+
};
|
|
16
|
+
export type DaemonSocket = {
|
|
17
|
+
send(text: string): void;
|
|
18
|
+
close(code: number, reason: string): void;
|
|
19
|
+
};
|
|
20
|
+
/** Opens a WebSocket to the daemon's web listener, with no `Origin`. */
|
|
21
|
+
export type OpenDaemonSocket = (url: string, events: DaemonSocketEvents) => DaemonSocket;
|
|
22
|
+
/** A tab the browser lists. */
|
|
23
|
+
export type DevtoolsTarget = {
|
|
24
|
+
/** The CDP target id, stable for the life of the tab. */
|
|
25
|
+
id: string;
|
|
26
|
+
url: string;
|
|
27
|
+
title: string;
|
|
28
|
+
};
|
|
29
|
+
/** A tab opened for relaying: the page channel plus the way to let go of it. */
|
|
30
|
+
export type DevtoolsPage = PageChannel & {
|
|
31
|
+
/** Drops the tab's binding connection. The page keeps running; it just has no relay. */
|
|
32
|
+
close(): void;
|
|
33
|
+
};
|
|
34
|
+
/** A Chromium launched with `--remote-debugging-port`, as the daemon sees it. */
|
|
35
|
+
export type DevtoolsBrowser = {
|
|
36
|
+
/** The open tabs, in the order the browser lists them. Rejects when the browser cannot be reached. */
|
|
37
|
+
targets(): Promise<DevtoolsTarget[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Opens the tab for relaying: `window.__appductBinding(json)` exists in its current document and in
|
|
40
|
+
* every later one, and `onContextReset` fires when its document is destroyed or the tab closes.
|
|
41
|
+
* Opening a tab that is already open drops the earlier connection.
|
|
42
|
+
*/
|
|
43
|
+
openPage(targetId: string): Promise<DevtoolsPage>;
|
|
44
|
+
/** Drops every connection `openPage` made. */
|
|
45
|
+
close(): void;
|
|
46
|
+
};
|
|
47
|
+
/** Reaches the browser whose debugging endpoint is `browserUrl`, such as `http://127.0.0.1:9222`. */
|
|
48
|
+
export type ConnectDevtoolsBrowser = (browserUrl: string) => DevtoolsBrowser;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type BootstrapPayload } from "@appduct/shared";
|
|
2
|
+
import type { OpenDaemonSocket, PageChannel } from "./ports.js";
|
|
3
|
+
/** A link from `link({ target: "web", url })`: the page URL with `#appduct=<payload>`. */
|
|
4
|
+
export type RelayLink = {
|
|
5
|
+
url: string;
|
|
6
|
+
};
|
|
7
|
+
/** The bootstrap payload in the link's fragment. */
|
|
8
|
+
export declare const linkPayload: (link: RelayLink) => string;
|
|
9
|
+
/** The bootstrap a link carries; throws when the link has no payload or the payload is not an Appduct link. */
|
|
10
|
+
export declare const parseLink: (link: RelayLink) => BootstrapPayload;
|
|
11
|
+
/**
|
|
12
|
+
* Carries one page's session frames between the page's binding and the daemon's web listener. The
|
|
13
|
+
* page asks for a daemon socket when its transport opens, and the relay closes it when the page's
|
|
14
|
+
* document is destroyed, so the daemon suspends the session and the reloaded page resumes it.
|
|
15
|
+
*/
|
|
16
|
+
export declare const relayPage: (page: PageChannel, link: RelayLink, openDaemonSocket: OpenDaemonSocket) => void;
|
package/dist/index.js
CHANGED
|
@@ -126,8 +126,11 @@ var createCli = () => {
|
|
|
126
126
|
`link: deep-link URI scheme (also: APPDUCT_SCHEME; default: .appduct/config.json, then app.json's "expo.scheme", then the Android/iOS project files in <cwd>).`
|
|
127
127
|
).option(
|
|
128
128
|
"--open <target>",
|
|
129
|
-
"link: deliver the link automatically via adb/simctl/devicectl (android|ios-sim|ios-device; ios-device is experimental)."
|
|
130
|
-
).option(
|
|
129
|
+
"link: deliver the link automatically via adb/simctl/devicectl (android|ios-sim|ios-device; ios-device is experimental), or with `web <url>` return the page URL and a connect script for a web page."
|
|
130
|
+
).option(
|
|
131
|
+
"--browser-url <url>",
|
|
132
|
+
"link: with --open web, attach the tab of a Chrome launched with --remote-debugging-port (such as http://127.0.0.1:9222) and claim a session on it."
|
|
133
|
+
).option("--target-id <id>", "link: with --browser-url, the CDP target id of the tab to attach when several tabs match <url>.").option("--device <id>", "link: adb serial, simulator udid or paired-device udid to target when --open is ambiguous.").option(
|
|
131
134
|
"--app-id <id>",
|
|
132
135
|
`link: installed app id for --open android/ios-device (default: .appduct/config.json's "appId.<platform>").`
|
|
133
136
|
).option(
|
|
@@ -138,10 +141,10 @@ var createCli = () => {
|
|
|
138
141
|
"--timeout <ms>",
|
|
139
142
|
"call: call timeout in milliseconds. Shortens the deadline; it cannot extend one past the app's own timer, which is the tool's declared timeoutMs (else 10000)."
|
|
140
143
|
);
|
|
141
|
-
cli.command("events [...args]", "
|
|
144
|
+
cli.command("events [...args]", "List the events an app declares, stream them, or replay them since a cursor: ls, tail, or since.").usage("events <ls|tail|since> [selector] [args]").option("--follow", "tail: accepted for script readability; the default behavior already follows.").option(
|
|
142
145
|
"--name <glob>",
|
|
143
|
-
|
|
144
|
-
).option(
|
|
146
|
+
`Only events whose name matches this whole-name, case-sensitive glob ("*" matches any run of characters). ls: an exact name prints that event's full payload schema.`
|
|
147
|
+
).option("--limit <n>", "ls: show at most n events.").option("--offset <n>", "ls: skip the first n events of the name-sorted list.").option(
|
|
145
148
|
"--payload-max-bytes <n>",
|
|
146
149
|
"Cap each event's payload at this many UTF-8 bytes of its JSON; over the cap, the event carries payloadPreview, payloadBytes and truncated: true instead of payload."
|
|
147
150
|
);
|
|
@@ -169,6 +172,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
|
|
|
169
172
|
"daemonLogMaxBytes",
|
|
170
173
|
"eventsLogMaxBytes",
|
|
171
174
|
"policy",
|
|
175
|
+
"webOrigins",
|
|
172
176
|
"advertisedIp",
|
|
173
177
|
"scheme",
|
|
174
178
|
"restartDaemonOnVersionMismatch"
|
|
@@ -229,6 +233,7 @@ var defaultConfig = (paths) => {
|
|
|
229
233
|
daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
|
|
230
234
|
eventsLogMaxBytes: 10 * 1024 * 1024,
|
|
231
235
|
restartDaemonOnVersionMismatch: false,
|
|
236
|
+
webOrigins: [],
|
|
232
237
|
policy: {
|
|
233
238
|
default: "allow",
|
|
234
239
|
destructive: "allow"
|
|
@@ -299,6 +304,12 @@ var loadConfig = async (paths, options = {}) => {
|
|
|
299
304
|
"restartDaemonOnVersionMismatch"
|
|
300
305
|
);
|
|
301
306
|
}
|
|
307
|
+
if (parsed.webOrigins !== void 0) {
|
|
308
|
+
if (!Array.isArray(parsed.webOrigins)) {
|
|
309
|
+
throw configError("webOrigins", "must be an array of origin strings.");
|
|
310
|
+
}
|
|
311
|
+
config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
|
|
312
|
+
}
|
|
302
313
|
if (parsed.policy !== void 0) {
|
|
303
314
|
if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
|
|
304
315
|
throw configError("policy", "must be an object.");
|
|
@@ -407,7 +418,7 @@ var EXIT_CODE_BY_ERROR_TYPE = {
|
|
|
407
418
|
// EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
|
|
408
419
|
// missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
|
|
409
420
|
// code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
|
|
410
|
-
// assertion failure
|
|
421
|
+
// assertion failure.
|
|
411
422
|
inspection_error: 66,
|
|
412
423
|
connection_error: 70,
|
|
413
424
|
session_error: 71,
|
|
@@ -546,6 +557,7 @@ var getExitCodeForError = (error) => {
|
|
|
546
557
|
import pc from "picocolors";
|
|
547
558
|
import {
|
|
548
559
|
formatAgentWebSocketUrl,
|
|
560
|
+
renderEventSignature,
|
|
549
561
|
renderToolSignature,
|
|
550
562
|
summarizeToolDescription
|
|
551
563
|
} from "@appduct/shared";
|
|
@@ -641,6 +653,13 @@ var formatDevice = (device) => {
|
|
|
641
653
|
const label = parts.length > 0 ? parts.join(" ") : "unknown device";
|
|
642
654
|
return device.os ? `${label} (${device.os})` : label;
|
|
643
655
|
};
|
|
656
|
+
var SUSPEND_REASON_LABELS = {
|
|
657
|
+
app_backgrounded: "app in background",
|
|
658
|
+
connection_lost: "connection lost"
|
|
659
|
+
};
|
|
660
|
+
var formatSessionState = (session) => {
|
|
661
|
+
return session.suspendReason ? `${session.state} (${SUSPEND_REASON_LABELS[session.suspendReason]})` : session.state;
|
|
662
|
+
};
|
|
644
663
|
var renderLsData = (colors, data, now) => {
|
|
645
664
|
if (data.length === 0) {
|
|
646
665
|
return [colors.dim("Sessions"), " No Appduct sessions are registered."];
|
|
@@ -649,7 +668,7 @@ var renderLsData = (colors, data, now) => {
|
|
|
649
668
|
const headers = ["Alias", "State", "Device", "Tools", "Age"];
|
|
650
669
|
const rows = data.map((session) => [
|
|
651
670
|
session.alias,
|
|
652
|
-
session
|
|
671
|
+
formatSessionState(session),
|
|
653
672
|
formatDevice(session.device),
|
|
654
673
|
String(session.toolCount),
|
|
655
674
|
formatAge(session.claimedAt ?? session.createdAt, nowIso)
|
|
@@ -823,10 +842,87 @@ var renderToolsListData = (colors, data, flags, full) => {
|
|
|
823
842
|
}
|
|
824
843
|
return full ? renderToolsFullListing(colors, data, flags) : renderToolSummaryTable(colors, data);
|
|
825
844
|
};
|
|
845
|
+
var renderEventsTruncationLine = (data) => {
|
|
846
|
+
const offset = data.offset ?? 0;
|
|
847
|
+
if (data.events.length >= data.total) {
|
|
848
|
+
return [];
|
|
849
|
+
}
|
|
850
|
+
return [
|
|
851
|
+
"",
|
|
852
|
+
`Showing ${data.events.length} of ${data.total} events (offset ${offset}). Narrow with --name <glob> or page with --offset <n>.`
|
|
853
|
+
];
|
|
854
|
+
};
|
|
855
|
+
var renderEventsListData = (colors, data, flags) => {
|
|
856
|
+
const [only] = data.events;
|
|
857
|
+
if (data.name !== void 0 && !data.name.includes("*") && only !== void 0) {
|
|
858
|
+
return renderFields(
|
|
859
|
+
colors.green(`Event: ${only.name}`),
|
|
860
|
+
[
|
|
861
|
+
["Signature", renderEventSignature(only)],
|
|
862
|
+
["Description", only.description],
|
|
863
|
+
["Payload schema", only.payload_schema]
|
|
864
|
+
],
|
|
865
|
+
flags
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
if (data.events.length === 0 && data.total > 0) {
|
|
869
|
+
return [
|
|
870
|
+
colors.green("Events"),
|
|
871
|
+
` No events at offset ${data.offset ?? 0}; ${data.total} matching event${data.total === 1 ? "" : "s"} in total.`
|
|
872
|
+
];
|
|
873
|
+
}
|
|
874
|
+
if (data.events.length === 0) {
|
|
875
|
+
return [
|
|
876
|
+
colors.green("Events"),
|
|
877
|
+
data.name === void 0 ? " No events declared." : ` No events match ${JSON.stringify(data.name)}.`
|
|
878
|
+
];
|
|
879
|
+
}
|
|
880
|
+
const lines = data.events.flatMap((event) => [
|
|
881
|
+
` ${renderEventSignature(event)}`,
|
|
882
|
+
` ${summarizeToolDescription(event.description)}`
|
|
883
|
+
]);
|
|
884
|
+
return [
|
|
885
|
+
colors.green("Events"),
|
|
886
|
+
...lines,
|
|
887
|
+
...renderEventsTruncationLine(data),
|
|
888
|
+
"",
|
|
889
|
+
"Run `appduct events ls --name <name>` for an event's full payload schema."
|
|
890
|
+
];
|
|
891
|
+
};
|
|
826
892
|
var renderInvokeData = (colors, data, flags) => {
|
|
827
893
|
return [colors.green("Result"), formatScalar(data, flags)];
|
|
828
894
|
};
|
|
829
895
|
var renderLinkData = (colors, data, flags, qr) => {
|
|
896
|
+
if ("attached" in data) {
|
|
897
|
+
return [
|
|
898
|
+
colors.green("Page Attached"),
|
|
899
|
+
...renderFields(
|
|
900
|
+
"Page",
|
|
901
|
+
[
|
|
902
|
+
["Session", data.sessionId],
|
|
903
|
+
["Tab", data.targetId],
|
|
904
|
+
["URL", data.url],
|
|
905
|
+
["Expires", new Date(data.expiresAt * 1e3).toISOString()]
|
|
906
|
+
],
|
|
907
|
+
flags
|
|
908
|
+
)
|
|
909
|
+
];
|
|
910
|
+
}
|
|
911
|
+
if ("script" in data) {
|
|
912
|
+
return [
|
|
913
|
+
colors.green("Link Created"),
|
|
914
|
+
...renderFields(
|
|
915
|
+
"Link",
|
|
916
|
+
[
|
|
917
|
+
["Session", data.sessionId],
|
|
918
|
+
["URL", data.url],
|
|
919
|
+
["Script", data.script],
|
|
920
|
+
["Expires", new Date(data.expiresAt * 1e3).toISOString()]
|
|
921
|
+
],
|
|
922
|
+
flags
|
|
923
|
+
)
|
|
924
|
+
];
|
|
925
|
+
}
|
|
830
926
|
const lines = [
|
|
831
927
|
colors.green("Link Created"),
|
|
832
928
|
...renderFields(
|
|
@@ -947,6 +1043,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
|
|
|
947
1043
|
["PID", data.daemon.pid],
|
|
948
1044
|
["Started at", data.daemon.started_at],
|
|
949
1045
|
["WSS port", data.daemon.wss_port],
|
|
1046
|
+
["Web port", data.daemon.web_port],
|
|
950
1047
|
["Pinned keys", data.daemon.pinned_keys.length],
|
|
951
1048
|
["Sessions", data.daemon.session_count]
|
|
952
1049
|
],
|
|
@@ -1014,6 +1111,8 @@ var renderSuccessData = (colors, command, data, options) => {
|
|
|
1014
1111
|
return renderToolsListData(colors, data, flags, options.full);
|
|
1015
1112
|
case "tools describe":
|
|
1016
1113
|
return renderToolDetail(colors, data, flags);
|
|
1114
|
+
case "events ls":
|
|
1115
|
+
return renderEventsListData(colors, data, flags);
|
|
1017
1116
|
case "tools call":
|
|
1018
1117
|
return renderInvokeData(colors, data, flags);
|
|
1019
1118
|
case "sessions revoke":
|