appduct 0.9.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/LICENSE +21 -0
- package/README.md +274 -0
- package/bin.js +2 -0
- package/dist/artifact-inspect.d.ts +110 -0
- package/dist/artifact-inspect.d.ts.map +1 -0
- package/dist/artifact-inspect.js +315 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/cli/command-options.d.ts +44 -0
- package/dist/cli/command-options.d.ts.map +1 -0
- package/dist/cli/command-options.js +95 -0
- package/dist/cli/create-cli.d.ts +2 -0
- package/dist/cli/create-cli.d.ts.map +1 -0
- package/dist/cli/create-cli.js +56 -0
- package/dist/cli/dispatch.d.ts +3 -0
- package/dist/cli/dispatch.d.ts.map +1 -0
- package/dist/cli/dispatch.js +275 -0
- package/dist/cli/open-target.d.ts +112 -0
- package/dist/cli/open-target.d.ts.map +1 -0
- package/dist/cli/open-target.js +438 -0
- package/dist/cli/result-types.d.ts +194 -0
- package/dist/cli/result-types.d.ts.map +1 -0
- package/dist/cli/result-types.js +6 -0
- package/dist/cli/runner.d.ts +35 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +111 -0
- package/dist/cli/types.d.ts +20 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +10 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client/app-client.d.ts +126 -0
- package/dist/client/app-client.d.ts.map +1 -0
- package/dist/client/app-client.js +223 -0
- package/dist/client/bootstrap.d.ts +48 -0
- package/dist/client/bootstrap.d.ts.map +1 -0
- package/dist/client/bootstrap.js +108 -0
- package/dist/client/connect.d.ts +17 -0
- package/dist/client/connect.d.ts.map +1 -0
- package/dist/client/connect.js +38 -0
- package/dist/client/errors.d.ts +26 -0
- package/dist/client/errors.d.ts.map +1 -0
- package/dist/client/errors.js +40 -0
- package/dist/client/index.d.ts +19 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +16 -0
- package/dist/commands/daemon.d.ts +22 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +128 -0
- package/dist/commands/doctor.d.ts +23 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/events.d.ts +37 -0
- package/dist/commands/events.d.ts.map +1 -0
- package/dist/commands/events.js +76 -0
- package/dist/commands/init.d.ts +51 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +231 -0
- package/dist/commands/invoke.d.ts +22 -0
- package/dist/commands/invoke.d.ts.map +1 -0
- package/dist/commands/invoke.js +54 -0
- package/dist/commands/keygen.d.ts +23 -0
- package/dist/commands/keygen.d.ts.map +1 -0
- package/dist/commands/keygen.js +46 -0
- package/dist/commands/link.d.ts +30 -0
- package/dist/commands/link.d.ts.map +1 -0
- package/dist/commands/link.js +43 -0
- package/dist/commands/ls.d.ts +9 -0
- package/dist/commands/ls.d.ts.map +1 -0
- package/dist/commands/ls.js +7 -0
- package/dist/commands/mcp.d.ts +49 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +98 -0
- package/dist/commands/revoke.d.ts +12 -0
- package/dist/commands/revoke.d.ts.map +1 -0
- package/dist/commands/revoke.js +7 -0
- package/dist/commands/tools.d.ts +23 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +52 -0
- package/dist/daemon/address.d.ts +30 -0
- package/dist/daemon/address.d.ts.map +1 -0
- package/dist/daemon/address.js +71 -0
- package/dist/daemon/audit.d.ts +107 -0
- package/dist/daemon/audit.d.ts.map +1 -0
- package/dist/daemon/audit.js +237 -0
- package/dist/daemon/calls.d.ts +84 -0
- package/dist/daemon/calls.d.ts.map +1 -0
- package/dist/daemon/calls.js +158 -0
- package/dist/daemon/config.d.ts +79 -0
- package/dist/daemon/config.d.ts.map +1 -0
- package/dist/daemon/config.js +170 -0
- package/dist/daemon/daemon.d.ts +43 -0
- package/dist/daemon/daemon.d.ts.map +1 -0
- package/dist/daemon/daemon.js +560 -0
- package/dist/daemon/event-bus.d.ts +49 -0
- package/dist/daemon/event-bus.d.ts.map +1 -0
- package/dist/daemon/event-bus.js +111 -0
- package/dist/daemon/links.d.ts +54 -0
- package/dist/daemon/links.d.ts.map +1 -0
- package/dist/daemon/links.js +118 -0
- package/dist/daemon/listener.d.ts +34 -0
- package/dist/daemon/listener.d.ts.map +1 -0
- package/dist/daemon/listener.js +132 -0
- package/dist/daemon/log-rotation.d.ts +82 -0
- package/dist/daemon/log-rotation.d.ts.map +1 -0
- package/dist/daemon/log-rotation.js +136 -0
- package/dist/daemon/pidfile.d.ts +38 -0
- package/dist/daemon/pidfile.d.ts.map +1 -0
- package/dist/daemon/pidfile.js +90 -0
- package/dist/daemon/policy.d.ts +22 -0
- package/dist/daemon/policy.d.ts.map +1 -0
- package/dist/daemon/policy.js +24 -0
- package/dist/daemon/registry.d.ts +19 -0
- package/dist/daemon/registry.d.ts.map +1 -0
- package/dist/daemon/registry.js +31 -0
- package/dist/daemon/rpc-server.d.ts +54 -0
- package/dist/daemon/rpc-server.d.ts.map +1 -0
- package/dist/daemon/rpc-server.js +203 -0
- package/dist/daemon/sessions.d.ts +86 -0
- package/dist/daemon/sessions.d.ts.map +1 -0
- package/dist/daemon/sessions.js +429 -0
- package/dist/daemon/socket-probe.d.ts +16 -0
- package/dist/daemon/socket-probe.d.ts.map +1 -0
- package/dist/daemon/socket-probe.js +28 -0
- package/dist/daemon/state-dir.d.ts +31 -0
- package/dist/daemon/state-dir.d.ts.map +1 -0
- package/dist/daemon/state-dir.js +43 -0
- package/dist/daemon/timers.d.ts +16 -0
- package/dist/daemon/timers.d.ts.map +1 -0
- package/dist/daemon/timers.js +12 -0
- package/dist/daemon/tls.d.ts +58 -0
- package/dist/daemon/tls.d.ts.map +1 -0
- package/dist/daemon/tls.js +79 -0
- package/dist/errors.d.ts +28 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +162 -0
- package/dist/host-certificate.d.ts +8 -0
- package/dist/host-certificate.d.ts.map +1 -0
- package/dist/host-certificate.js +111 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/key-material.d.ts +14 -0
- package/dist/key-material.d.ts.map +1 -0
- package/dist/key-material.js +42 -0
- package/dist/link.d.ts +76 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +117 -0
- package/dist/mcp/connect-tool.d.ts +115 -0
- package/dist/mcp/connect-tool.d.ts.map +1 -0
- package/dist/mcp/connect-tool.js +396 -0
- package/dist/mcp/daemon-tools.d.ts +13 -0
- package/dist/mcp/daemon-tools.d.ts.map +1 -0
- package/dist/mcp/daemon-tools.js +29 -0
- package/dist/mcp/events-tool.d.ts +111 -0
- package/dist/mcp/events-tool.d.ts.map +1 -0
- package/dist/mcp/events-tool.js +322 -0
- package/dist/mcp/server.d.ts +58 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +538 -0
- package/dist/mcp/tool-mapping.d.ts +58 -0
- package/dist/mcp/tool-mapping.d.ts.map +1 -0
- package/dist/mcp/tool-mapping.js +143 -0
- package/dist/mcp/tool-namespace.d.ts +40 -0
- package/dist/mcp/tool-namespace.d.ts.map +1 -0
- package/dist/mcp/tool-namespace.js +65 -0
- package/dist/native-scheme.d.ts +59 -0
- package/dist/native-scheme.d.ts.map +1 -0
- package/dist/native-scheme.js +465 -0
- package/dist/output.d.ts +28 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +342 -0
- package/dist/package-version.d.ts +30 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +47 -0
- package/dist/qr-terminal.d.ts +13 -0
- package/dist/qr-terminal.d.ts.map +1 -0
- package/dist/qr-terminal.js +45 -0
- package/dist/rpc/client.d.ts +137 -0
- package/dist/rpc/client.d.ts.map +1 -0
- package/dist/rpc/client.js +819 -0
- package/dist/scheme.d.ts +181 -0
- package/dist/scheme.d.ts.map +1 -0
- package/dist/scheme.js +321 -0
- package/dist/spki-pin.d.ts +3 -0
- package/dist/spki-pin.d.ts.map +1 -0
- package/dist/spki-pin.js +14 -0
- package/package.json +65 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session state machine (ARCHITECTURE.md §6, exactly): PENDING (owned by links.ts) →
|
|
3
|
+
* ACTIVE ⇄ SUSPENDED → EXPIRED, with REVOKED reachable from any live state. This module owns the
|
|
4
|
+
* live-session map, claim/resume handling (including the pre-auth hardening in the task's
|
|
5
|
+
* rejection matrix), alias assignment, keepalive-driven suspend, and grace-timer expiry.
|
|
6
|
+
*
|
|
7
|
+
* Pending links never enter `sessions` — they live in links.ts's `PendingLinkRegistry` and are
|
|
8
|
+
* only ever resolved during claim. `sessions` therefore only ever holds live (active/suspended)
|
|
9
|
+
* sessions; terminal transitions (revoked/expired) remove the entry immediately (ARCHITECTURE.md
|
|
10
|
+
* §6: "terminal states free the alias" — there is no persisted history, matching §3/§13's "no
|
|
11
|
+
* per-session files, all state in-daemon-memory").
|
|
12
|
+
*/
|
|
13
|
+
import { type EventMessage, type SessionClaimMessage, type SessionResumeMessage, type SessionSummary, type ToolCallMessage, type ToolCallProgressMessage, type ToolCancelMessage, type ToolErrorMessage, type ToolResultMessage } from "@appduct/shared";
|
|
14
|
+
import type { WebSocket } from "ws";
|
|
15
|
+
import type { Clock } from "../cli/types.js";
|
|
16
|
+
import type { EventBus } from "./event-bus.js";
|
|
17
|
+
import { type CreatedLink } from "./links.js";
|
|
18
|
+
import { type ToolRegistry } from "./registry.js";
|
|
19
|
+
import { type TimerFns } from "./timers.js";
|
|
20
|
+
/** Wire message types allowed *after* claim (session_claim/session_resume are pre-claim only). */
|
|
21
|
+
export declare const POST_CLAIM_MESSAGE_TYPES: Set<string>;
|
|
22
|
+
export declare const isPostClaimMessageType: (type: unknown) => type is string;
|
|
23
|
+
export declare const slugifyDeviceModel: (model?: string) => string;
|
|
24
|
+
export declare const dedupeAlias: (base: string, existingAliases: ReadonlySet<string>) => string;
|
|
25
|
+
export type SessionManagerOptions = {
|
|
26
|
+
graceSeconds: number;
|
|
27
|
+
keepaliveIntervalSeconds: number;
|
|
28
|
+
linkTtlSeconds: number;
|
|
29
|
+
getEndpoint: () => CreatedLink["endpoint"];
|
|
30
|
+
eventBus: EventBus;
|
|
31
|
+
clock?: Clock;
|
|
32
|
+
timers?: TimerFns;
|
|
33
|
+
/** Routes a validated `tool_result`/`tool_error`/`tool_call_progress` frame (`calls.ts`
|
|
34
|
+
* correlates it); wired by the composition root once both modules exist. */
|
|
35
|
+
onToolFrame?: (message: ToolResultMessage | ToolErrorMessage | ToolCallProgressMessage) => void;
|
|
36
|
+
/** Routes a validated app-side `event` frame; the composition root forwards it to the event bus
|
|
37
|
+
* as `app_event` (ARCHITECTURE.md §7). */
|
|
38
|
+
onAppEvent?: (sessionId: string, alias: string, message: EventMessage) => void;
|
|
39
|
+
};
|
|
40
|
+
export type LinkCreateResult = {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
deepLinkPayload: string;
|
|
43
|
+
endpoint: CreatedLink["endpoint"];
|
|
44
|
+
expiresAt: number;
|
|
45
|
+
};
|
|
46
|
+
export type SessionManager = {
|
|
47
|
+
/** `addressOverride` forces the address encoded into this one link's bootstrap payload (task
|
|
48
|
+
* 07's emulator/simulator fast path); see `links.ts`'s `PendingLinkRegistry.create`. Omits
|
|
49
|
+
* `LinkCreateResult.pin` — the session manager has no `TlsManager` handle, so the RPC handler
|
|
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. Returns the claimed sessionId, or `null` after closing the socket. */
|
|
53
|
+
handleClaim: (socket: WebSocket, message: SessionClaimMessage) => string | null;
|
|
54
|
+
/** First message on a resume socket. Returns the resumed sessionId, or `null` after closing the socket. */
|
|
55
|
+
handleResume: (socket: WebSocket, message: SessionResumeMessage) => string | null;
|
|
56
|
+
/** Dispatches an already-type-checked post-claim message; closes the socket on invalid content. */
|
|
57
|
+
handlePostClaimMessage: (sessionId: string, socket: WebSocket, message: Record<string, unknown>) => void;
|
|
58
|
+
list: () => SessionSummary[];
|
|
59
|
+
/** Links that are still claimable: minted, unclaimed, and inside their TTL. Not sessions (§6),
|
|
60
|
+
* but live state a daemon restart destroys, so `daemon.status` surfaces the count for issue
|
|
61
|
+
* #30's safety decision. */
|
|
62
|
+
pendingLinkCount: () => number;
|
|
63
|
+
describe: (selector?: string) => SessionSummary;
|
|
64
|
+
revoke: (selector?: string) => void;
|
|
65
|
+
/** Resolves a selector for the `tools.*` RPC surface: same selector rules as
|
|
66
|
+
* `describe`/`revoke` (no_session/ambiguous_session/unknown_session), but returns the pieces
|
|
67
|
+
* `tools.list`/`tools.call` need directly rather than a wire-shaped summary. */
|
|
68
|
+
resolveForTools: (selector?: string) => {
|
|
69
|
+
sessionId: string;
|
|
70
|
+
alias: string;
|
|
71
|
+
state: "active" | "suspended";
|
|
72
|
+
registry: ToolRegistry;
|
|
73
|
+
};
|
|
74
|
+
/** Sends a `tool_call` frame to the session's active socket; `false` if the session has no
|
|
75
|
+
* active socket right now (caller has typically already checked ACTIVE state via
|
|
76
|
+
* {@link resolveForTools}, but the socket can still disappear between check and send). */
|
|
77
|
+
sendToolCall: (sessionId: string, message: ToolCallMessage) => boolean;
|
|
78
|
+
/** Sends a `tool_cancel` frame to the session's active socket; `false` if the session has no
|
|
79
|
+
* active socket right now. A cancel for an unknown/finished call is the app's problem to
|
|
80
|
+
* no-op on — the daemon never knows which calls the app still considers in flight. */
|
|
81
|
+
sendToolCancel: (sessionId: string, message: ToolCancelMessage) => boolean;
|
|
82
|
+
/** Closes every live socket with the given code (daemon shutdown) and clears all timers. */
|
|
83
|
+
disposeAll: (code: number, reason: string) => void;
|
|
84
|
+
};
|
|
85
|
+
export declare const createSessionManager: (options: SessionManagerOptions) => SessionManager;
|
|
86
|
+
//# sourceMappingURL=sessions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/daemon/sessions.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session state machine (ARCHITECTURE.md §6, exactly): PENDING (owned by links.ts) →
|
|
3
|
+
* ACTIVE ⇄ SUSPENDED → EXPIRED, with REVOKED reachable from any live state. This module owns the
|
|
4
|
+
* live-session map, claim/resume handling (including the pre-auth hardening in the task's
|
|
5
|
+
* rejection matrix), alias assignment, keepalive-driven suspend, and grace-timer expiry.
|
|
6
|
+
*
|
|
7
|
+
* Pending links never enter `sessions` — they live in links.ts's `PendingLinkRegistry` and are
|
|
8
|
+
* only ever resolved during claim. `sessions` therefore only ever holds live (active/suspended)
|
|
9
|
+
* sessions; terminal transitions (revoked/expired) remove the entry immediately (ARCHITECTURE.md
|
|
10
|
+
* §6: "terminal states free the alias" — there is no persisted history, matching §3/§13's "no
|
|
11
|
+
* per-session files, all state in-daemon-memory").
|
|
12
|
+
*/
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { isEventMessage, isToolCallProgressMessage, isToolErrorMessage, isToolRegistryDeltaMessage, isToolRegistrySnapshotMessage, isToolResultMessage, parseSessionClaimDeviceFields, } from "@appduct/shared";
|
|
15
|
+
import { createPendingLinkRegistry } from "./links.js";
|
|
16
|
+
import { RpcApplicationError } from "./rpc-server.js";
|
|
17
|
+
import { createToolRegistry } from "./registry.js";
|
|
18
|
+
import { systemTimers } from "./timers.js";
|
|
19
|
+
const RESUME_TOKEN_BYTES = 32;
|
|
20
|
+
/** Wire message types allowed *after* claim (session_claim/session_resume are pre-claim only). */
|
|
21
|
+
export const POST_CLAIM_MESSAGE_TYPES = new Set([
|
|
22
|
+
"tool_registry_snapshot",
|
|
23
|
+
"tool_registry_delta",
|
|
24
|
+
"tool_result",
|
|
25
|
+
"tool_error",
|
|
26
|
+
"tool_call_progress",
|
|
27
|
+
"event",
|
|
28
|
+
]);
|
|
29
|
+
export const isPostClaimMessageType = (type) => {
|
|
30
|
+
return typeof type === "string" && POST_CLAIM_MESSAGE_TYPES.has(type);
|
|
31
|
+
};
|
|
32
|
+
export const slugifyDeviceModel = (model) => {
|
|
33
|
+
if (!model) {
|
|
34
|
+
return "device";
|
|
35
|
+
}
|
|
36
|
+
const slug = model
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replaceAll(/[^a-z0-9]+/gu, "-")
|
|
39
|
+
.replaceAll(/^-+|-+$/gu, "");
|
|
40
|
+
return slug.length > 0 ? slug : "device";
|
|
41
|
+
};
|
|
42
|
+
export const dedupeAlias = (base, existingAliases) => {
|
|
43
|
+
if (!existingAliases.has(base)) {
|
|
44
|
+
return base;
|
|
45
|
+
}
|
|
46
|
+
let suffix = 2;
|
|
47
|
+
while (existingAliases.has(`${base}-${suffix}`)) {
|
|
48
|
+
suffix += 1;
|
|
49
|
+
}
|
|
50
|
+
return `${base}-${suffix}`;
|
|
51
|
+
};
|
|
52
|
+
const decodeFixedLengthToken = (base64url, expectedLength) => {
|
|
53
|
+
let decoded;
|
|
54
|
+
try {
|
|
55
|
+
decoded = Buffer.from(base64url, "base64url");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return decoded.length === expectedLength ? decoded : null;
|
|
61
|
+
};
|
|
62
|
+
/** Constant-time-safe comparison: never calls `timingSafeEqual` on mismatched lengths (it throws). */
|
|
63
|
+
const tokensMatch = (candidate, expected) => {
|
|
64
|
+
if (!candidate || candidate.length !== expected.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return timingSafeEqual(candidate, expected);
|
|
68
|
+
};
|
|
69
|
+
const MAX_FAILED_CLAIM_ATTEMPTS = 5;
|
|
70
|
+
const closeSocket = (socket, code, reason) => {
|
|
71
|
+
try {
|
|
72
|
+
socket.close(code, reason);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// The socket may already be closing/closed; never let a double-close crash the daemon.
|
|
76
|
+
socket.terminate();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
export const createSessionManager = (options) => {
|
|
80
|
+
const clock = options.clock ?? { now: () => new Date() };
|
|
81
|
+
const timers = options.timers ?? systemTimers;
|
|
82
|
+
const sessions = new Map();
|
|
83
|
+
const pendingLinks = createPendingLinkRegistry({
|
|
84
|
+
getEndpoint: options.getEndpoint,
|
|
85
|
+
clock,
|
|
86
|
+
timers,
|
|
87
|
+
eventBus: options.eventBus,
|
|
88
|
+
});
|
|
89
|
+
const emit = (kind, sessionId, alias, data) => {
|
|
90
|
+
options.eventBus.emit({ kind, sessionId, alias, data });
|
|
91
|
+
};
|
|
92
|
+
const toSummary = (session) => {
|
|
93
|
+
return {
|
|
94
|
+
sessionId: session.sessionId,
|
|
95
|
+
alias: session.alias,
|
|
96
|
+
state: session.state,
|
|
97
|
+
device: session.device,
|
|
98
|
+
createdAt: session.createdAt.toISOString(),
|
|
99
|
+
claimedAt: session.claimedAt.toISOString(),
|
|
100
|
+
...(session.suspendedAt ? { suspendedAt: session.suspendedAt.toISOString() } : {}),
|
|
101
|
+
toolCount: session.registry.count(),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const resolveSession = (selector) => {
|
|
105
|
+
const live = Array.from(sessions.values());
|
|
106
|
+
if (selector !== undefined) {
|
|
107
|
+
const match = live.find((session) => session.sessionId === selector || session.alias === selector);
|
|
108
|
+
if (!match) {
|
|
109
|
+
throw new RpcApplicationError("unknown_session", `No session matches "${selector}".`);
|
|
110
|
+
}
|
|
111
|
+
return match;
|
|
112
|
+
}
|
|
113
|
+
if (live.length === 0) {
|
|
114
|
+
throw new RpcApplicationError("no_session", "No active or suspended session, and none was specified.");
|
|
115
|
+
}
|
|
116
|
+
if (live.length > 1) {
|
|
117
|
+
throw new RpcApplicationError("ambiguous_session", `Multiple sessions are live (${live.map((session) => session.alias).join(", ")}); specify a selector.`);
|
|
118
|
+
}
|
|
119
|
+
return live[0];
|
|
120
|
+
};
|
|
121
|
+
const assignAlias = (model) => {
|
|
122
|
+
const base = slugifyDeviceModel(model);
|
|
123
|
+
const existing = new Set(Array.from(sessions.values(), (session) => session.alias));
|
|
124
|
+
return dedupeAlias(base, existing);
|
|
125
|
+
};
|
|
126
|
+
const sendAck = (socket, session) => {
|
|
127
|
+
const ack = {
|
|
128
|
+
type: "session_ack",
|
|
129
|
+
session_id: session.sessionId,
|
|
130
|
+
status: "ok",
|
|
131
|
+
alias: session.alias,
|
|
132
|
+
resume_token: session.resumeToken.toString("base64url"),
|
|
133
|
+
keepalive_interval_s: options.keepaliveIntervalSeconds,
|
|
134
|
+
grace_s: options.graceSeconds,
|
|
135
|
+
};
|
|
136
|
+
socket.send(JSON.stringify(ack), (error) => {
|
|
137
|
+
if (error) {
|
|
138
|
+
// The 'close'/'error' listeners (attached below) own recovery; a failed send must never
|
|
139
|
+
// throw into the socket library's event loop turn (v1's exact defect).
|
|
140
|
+
closeSocket(socket, 1011, "send_failed");
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
const suspend = (sessionId) => {
|
|
145
|
+
const session = sessions.get(sessionId);
|
|
146
|
+
if (!session || session.state !== "active") {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
session.state = "suspended";
|
|
150
|
+
session.suspendedAt = clock.now();
|
|
151
|
+
session.socket = undefined;
|
|
152
|
+
session.missedPongs = 0;
|
|
153
|
+
session.graceTimer = timers.setTimeout(() => expire(sessionId), options.graceSeconds * 1000);
|
|
154
|
+
emit("session_suspended", session.sessionId, session.alias, {});
|
|
155
|
+
};
|
|
156
|
+
const expire = (sessionId) => {
|
|
157
|
+
const session = sessions.get(sessionId);
|
|
158
|
+
if (!session || session.state !== "suspended") {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
sessions.delete(sessionId);
|
|
162
|
+
emit("session_expired", session.sessionId, session.alias, {});
|
|
163
|
+
};
|
|
164
|
+
const attachSocketHandlers = (session) => {
|
|
165
|
+
const socket = session.socket;
|
|
166
|
+
socket.on("error", () => {
|
|
167
|
+
// Never let an unhandled 'error' crash the daemon (v1's fatal defect) — 'close' still fires
|
|
168
|
+
// after 'error' and drives the suspend transition.
|
|
169
|
+
});
|
|
170
|
+
socket.on("pong", () => {
|
|
171
|
+
session.missedPongs = 0;
|
|
172
|
+
});
|
|
173
|
+
socket.on("close", () => {
|
|
174
|
+
if (session.socket === socket) {
|
|
175
|
+
suspend(session.sessionId);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
// Server-side keepalive (ARCHITECTURE.md §7): ping every `keepaliveIntervalSeconds`; two missed
|
|
180
|
+
// pongs in a row is treated as socket loss and suspends the session (never crashes the daemon).
|
|
181
|
+
const keepaliveInterval = timers.setInterval(() => {
|
|
182
|
+
for (const session of sessions.values()) {
|
|
183
|
+
if (session.state !== "active" || !session.socket) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (session.missedPongs >= 2) {
|
|
187
|
+
const staleSocket = session.socket;
|
|
188
|
+
session.socket = undefined;
|
|
189
|
+
suspend(session.sessionId);
|
|
190
|
+
staleSocket.terminate();
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
session.missedPongs += 1;
|
|
194
|
+
try {
|
|
195
|
+
session.socket.ping();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// A ping failure is surfaced via the socket's own 'error'/'close' handlers.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}, options.keepaliveIntervalSeconds * 1000);
|
|
202
|
+
const createLink = (ttlSeconds, addressOverride) => {
|
|
203
|
+
const { link, deepLinkPayload, endpoint } = pendingLinks.create(ttlSeconds ?? options.linkTtlSeconds, addressOverride);
|
|
204
|
+
return {
|
|
205
|
+
sessionId: link.sessionId,
|
|
206
|
+
deepLinkPayload,
|
|
207
|
+
endpoint,
|
|
208
|
+
expiresAt: Math.floor(link.expiresAt.getTime() / 1000),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
const handleClaim = (socket, message) => {
|
|
212
|
+
const pending = pendingLinks.get(message.session_id);
|
|
213
|
+
if (!pending) {
|
|
214
|
+
if (sessions.has(message.session_id)) {
|
|
215
|
+
closeSocket(socket, 1008, "already_claimed");
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
closeSocket(socket, 1008, "unknown_session");
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const nowMs = clock.now().getTime();
|
|
223
|
+
if (pending.expiresAt.getTime() <= nowMs) {
|
|
224
|
+
pendingLinks.discard(pending.sessionId);
|
|
225
|
+
closeSocket(socket, 1008, "link_expired");
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const candidateToken = decodeFixedLengthToken(message.token, pending.token.length);
|
|
229
|
+
if (!tokensMatch(candidateToken, pending.token)) {
|
|
230
|
+
// ARCHITECTURE.md §6: the token is invalidated "after 5 failed claim attempts" — the 5th
|
|
231
|
+
// failure itself invalidates the link (a correct token on a would-be 6th attempt must not
|
|
232
|
+
// still claim).
|
|
233
|
+
const attempts = pendingLinks.recordFailedAttempt(pending.sessionId);
|
|
234
|
+
if (attempts >= MAX_FAILED_CLAIM_ATTEMPTS) {
|
|
235
|
+
pendingLinks.discard(pending.sessionId);
|
|
236
|
+
closeSocket(socket, 1008, "claim_attempts_exceeded");
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
closeSocket(socket, 1008, "invalid_token");
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
pendingLinks.consume(pending.sessionId);
|
|
244
|
+
const deviceFields = parseSessionClaimDeviceFields(message);
|
|
245
|
+
const device = deviceFields && deviceFields !== "invalid" ? deviceFields : {};
|
|
246
|
+
const alias = assignAlias(device.model);
|
|
247
|
+
const sessionId = pending.sessionId;
|
|
248
|
+
const session = {
|
|
249
|
+
sessionId,
|
|
250
|
+
alias,
|
|
251
|
+
state: "active",
|
|
252
|
+
device,
|
|
253
|
+
createdAt: pending.createdAt,
|
|
254
|
+
claimedAt: clock.now(),
|
|
255
|
+
resumeToken: randomBytes(RESUME_TOKEN_BYTES),
|
|
256
|
+
socket,
|
|
257
|
+
missedPongs: 0,
|
|
258
|
+
registry: createToolRegistry(() => {
|
|
259
|
+
emit("tools_changed", sessionId, alias, { toolCount: session.registry.count() });
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
262
|
+
sessions.set(sessionId, session);
|
|
263
|
+
attachSocketHandlers(session);
|
|
264
|
+
sendAck(socket, session);
|
|
265
|
+
emit("session_claimed", sessionId, alias, { device });
|
|
266
|
+
return sessionId;
|
|
267
|
+
};
|
|
268
|
+
const handleResume = (socket, message) => {
|
|
269
|
+
const session = sessions.get(message.session_id);
|
|
270
|
+
if (!session) {
|
|
271
|
+
closeSocket(socket, 1008, "unknown_session");
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
const candidateToken = decodeFixedLengthToken(message.resume_token, session.resumeToken.length);
|
|
275
|
+
if (!tokensMatch(candidateToken, session.resumeToken)) {
|
|
276
|
+
closeSocket(socket, 1008, "invalid_resume_token");
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
// Resuming an ACTIVE session is a socket replacement (e.g. app reconnected before the old
|
|
280
|
+
// socket's close event was observed) rather than an error: adopt the new socket, drop the old.
|
|
281
|
+
if (session.state === "active" && session.socket && session.socket !== socket) {
|
|
282
|
+
const staleSocket = session.socket;
|
|
283
|
+
session.socket = undefined; // prevents the stale socket's 'close' handler from suspending us
|
|
284
|
+
closeSocket(staleSocket, 1000, "session_replaced");
|
|
285
|
+
}
|
|
286
|
+
if (session.graceTimer) {
|
|
287
|
+
timers.clearTimeout(session.graceTimer);
|
|
288
|
+
session.graceTimer = undefined;
|
|
289
|
+
}
|
|
290
|
+
session.resumeToken = randomBytes(RESUME_TOKEN_BYTES);
|
|
291
|
+
session.state = "active";
|
|
292
|
+
session.suspendedAt = undefined;
|
|
293
|
+
session.socket = socket;
|
|
294
|
+
session.missedPongs = 0;
|
|
295
|
+
attachSocketHandlers(session);
|
|
296
|
+
sendAck(socket, session);
|
|
297
|
+
emit("session_resumed", session.sessionId, session.alias, {});
|
|
298
|
+
return session.sessionId;
|
|
299
|
+
};
|
|
300
|
+
const handlePostClaimMessage = (sessionId, socket, message) => {
|
|
301
|
+
const session = sessions.get(sessionId);
|
|
302
|
+
if (!session) {
|
|
303
|
+
closeSocket(socket, 1008, "unknown_session");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
switch (message.type) {
|
|
307
|
+
case "tool_registry_snapshot": {
|
|
308
|
+
if (!isToolRegistrySnapshotMessage(message)) {
|
|
309
|
+
closeSocket(socket, 1008, "invalid_registry");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
session.registry.snapshot(message.tools);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case "tool_registry_delta": {
|
|
316
|
+
if (!isToolRegistryDeltaMessage(message)) {
|
|
317
|
+
closeSocket(socket, 1008, "invalid_registry");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (message.operation === "upsert") {
|
|
321
|
+
session.registry.upsert(message.tool);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
session.registry.remove(message.name);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
case "tool_result": {
|
|
329
|
+
if (!isToolResultMessage(message)) {
|
|
330
|
+
closeSocket(socket, 1008, "invalid_message");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
options.onToolFrame?.(message);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
case "tool_error": {
|
|
337
|
+
if (!isToolErrorMessage(message)) {
|
|
338
|
+
closeSocket(socket, 1008, "invalid_message");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
options.onToolFrame?.(message);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
case "tool_call_progress": {
|
|
345
|
+
if (!isToolCallProgressMessage(message)) {
|
|
346
|
+
closeSocket(socket, 1008, "invalid_message");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
options.onToolFrame?.(message);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
case "event": {
|
|
353
|
+
if (!isEventMessage(message)) {
|
|
354
|
+
closeSocket(socket, 1008, "invalid_message");
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
options.onAppEvent?.(sessionId, session.alias, message);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
closeSocket(socket, 1008, "unknown_message_type");
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
const revoke = (selector) => {
|
|
365
|
+
// sessions.revoke is reachable from any state (ARCHITECTURE.md §6), including an unclaimed
|
|
366
|
+
// pending link — but only when explicitly selected by sessionId (pending links have no alias
|
|
367
|
+
// and are never eligible for the "exactly one live session" implicit-selector default).
|
|
368
|
+
if (selector !== undefined && !sessions.has(selector) && pendingLinks.get(selector)) {
|
|
369
|
+
pendingLinks.discard(selector);
|
|
370
|
+
emit("session_revoked", selector, undefined, {});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const session = resolveSession(selector);
|
|
374
|
+
if (session.graceTimer) {
|
|
375
|
+
timers.clearTimeout(session.graceTimer);
|
|
376
|
+
}
|
|
377
|
+
if (session.socket) {
|
|
378
|
+
closeSocket(session.socket, 1000, "revoked");
|
|
379
|
+
}
|
|
380
|
+
sessions.delete(session.sessionId);
|
|
381
|
+
emit("session_revoked", session.sessionId, session.alias, {});
|
|
382
|
+
};
|
|
383
|
+
const disposeAll = (code, reason) => {
|
|
384
|
+
timers.clearInterval(keepaliveInterval);
|
|
385
|
+
pendingLinks.disposeAll();
|
|
386
|
+
for (const session of sessions.values()) {
|
|
387
|
+
if (session.graceTimer) {
|
|
388
|
+
timers.clearTimeout(session.graceTimer);
|
|
389
|
+
}
|
|
390
|
+
if (session.socket) {
|
|
391
|
+
closeSocket(session.socket, code, reason);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
sessions.clear();
|
|
395
|
+
};
|
|
396
|
+
const resolveForTools = (selector) => {
|
|
397
|
+
const session = resolveSession(selector);
|
|
398
|
+
return { sessionId: session.sessionId, alias: session.alias, state: session.state, registry: session.registry };
|
|
399
|
+
};
|
|
400
|
+
const sendToApp = (sessionId, message) => {
|
|
401
|
+
const session = sessions.get(sessionId);
|
|
402
|
+
if (!session || session.state !== "active" || !session.socket) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
const socket = session.socket;
|
|
406
|
+
socket.send(JSON.stringify(message), (error) => {
|
|
407
|
+
if (error) {
|
|
408
|
+
// A failed send must never throw into the socket library's event loop turn (v1's exact
|
|
409
|
+
// defect); the resulting 'close'/'error' drives suspend, which rejects the pending call.
|
|
410
|
+
closeSocket(socket, 1011, "send_failed");
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return true;
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
createLink,
|
|
417
|
+
handleClaim,
|
|
418
|
+
handleResume,
|
|
419
|
+
handlePostClaimMessage,
|
|
420
|
+
list: () => Array.from(sessions.values(), toSummary),
|
|
421
|
+
pendingLinkCount: () => pendingLinks.claimableCount(),
|
|
422
|
+
describe: (selector) => toSummary(resolveSession(selector)),
|
|
423
|
+
revoke,
|
|
424
|
+
resolveForTools,
|
|
425
|
+
sendToolCall: sendToApp,
|
|
426
|
+
sendToolCancel: sendToApp,
|
|
427
|
+
disposeAll,
|
|
428
|
+
};
|
|
429
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is anything listening on the control socket?" — the cheapest liveness signal the state dir
|
|
3
|
+
* offers, and the one that answers fastest when a daemon is up.
|
|
4
|
+
*
|
|
5
|
+
* It lives in its own module rather than in `rpc/client.ts` (its first caller) because
|
|
6
|
+
* `daemon/log-rotation.ts` needs it too, and `rpc/client.ts` already imports *that*. Keeping the
|
|
7
|
+
* probe here means neither has to import the other.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Resolves `true` when a connection to `socketPath` is accepted. A refusal, a missing socket file,
|
|
11
|
+
* or any other error resolves `false` — this never rejects, so callers can use it as a plain
|
|
12
|
+
* predicate. Note it proves a listener exists *right now*; it cannot prove one will not appear a
|
|
13
|
+
* moment later.
|
|
14
|
+
*/
|
|
15
|
+
export declare const isSocketConnectable: (socketPath: string) => Promise<boolean>;
|
|
16
|
+
//# sourceMappingURL=socket-probe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"socket-probe.d.ts","sourceRoot":"","sources":["../../src/daemon/socket-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,GAAI,YAAY,MAAM,KAAG,OAAO,CAAC,OAAO,CAcvE,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is anything listening on the control socket?" — the cheapest liveness signal the state dir
|
|
3
|
+
* offers, and the one that answers fastest when a daemon is up.
|
|
4
|
+
*
|
|
5
|
+
* It lives in its own module rather than in `rpc/client.ts` (its first caller) because
|
|
6
|
+
* `daemon/log-rotation.ts` needs it too, and `rpc/client.ts` already imports *that*. Keeping the
|
|
7
|
+
* probe here means neither has to import the other.
|
|
8
|
+
*/
|
|
9
|
+
import { connect } from "node:net";
|
|
10
|
+
/**
|
|
11
|
+
* Resolves `true` when a connection to `socketPath` is accepted. A refusal, a missing socket file,
|
|
12
|
+
* or any other error resolves `false` — this never rejects, so callers can use it as a plain
|
|
13
|
+
* predicate. Note it proves a listener exists *right now*; it cannot prove one will not appear a
|
|
14
|
+
* moment later.
|
|
15
|
+
*/
|
|
16
|
+
export const isSocketConnectable = (socketPath) => {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const socket = connect(socketPath);
|
|
19
|
+
socket.once("connect", () => {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
resolve(true);
|
|
22
|
+
});
|
|
23
|
+
socket.once("error", () => {
|
|
24
|
+
socket.destroy();
|
|
25
|
+
resolve(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State directory resolution and layout (ARCHITECTURE.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Default `~/.appduct/`, overridable with `APPDUCT_STATE_DIR` (tests rely on the
|
|
5
|
+
* override). Created lazily with mode `0700`.
|
|
6
|
+
*/
|
|
7
|
+
export type StateDirPaths = {
|
|
8
|
+
/** The state directory root. */
|
|
9
|
+
root: string;
|
|
10
|
+
/** Unix domain control socket. Windows named pipes would plug in behind {@link getSocketPath}. */
|
|
11
|
+
socketPath: string;
|
|
12
|
+
pidFilePath: string;
|
|
13
|
+
logFilePath: string;
|
|
14
|
+
keyPath: string;
|
|
15
|
+
configPath: string;
|
|
16
|
+
auditDir: string;
|
|
17
|
+
/** Exclusive spawn-lock file used by the auto-spawning RPC client to prevent double-spawn races. */
|
|
18
|
+
spawnLockPath: string;
|
|
19
|
+
};
|
|
20
|
+
/** Resolves the state directory: explicit `override`, else `APPDUCT_STATE_DIR`, else `~/.appduct`. */
|
|
21
|
+
export declare const resolveStateDir: (override?: string) => string;
|
|
22
|
+
export declare const getStateDirPaths: (stateDir: string) => StateDirPaths;
|
|
23
|
+
/**
|
|
24
|
+
* Single seam for the control-socket address. Today this is always the UDS path; a Windows
|
|
25
|
+
* named-pipe implementation (`\\.\pipe\appduct-<user>`) plugs in here without touching any
|
|
26
|
+
* caller (ARCHITECTURE.md §13 — best-effort, not blocking v2.0).
|
|
27
|
+
*/
|
|
28
|
+
export declare const getSocketPath: (paths: StateDirPaths) => string;
|
|
29
|
+
/** Creates the state dir (and `audit/`) with mode `0700` if missing; tightens the mode if it exists. */
|
|
30
|
+
export declare const ensureStateDir: (stateDir: string) => Promise<StateDirPaths>;
|
|
31
|
+
//# sourceMappingURL=state-dir.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state-dir.d.ts","sourceRoot":"","sources":["../../src/daemon/state-dir.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH,MAAM,MAAM,aAAa,GAAG;IAC1B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,kGAAkG;IAClG,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,oGAAoG;IACpG,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,sGAAsG;AACtG,eAAO,MAAM,eAAe,GAAI,WAAW,MAAM,KAAG,MAEnD,CAAC;AAEF,eAAO,MAAM,gBAAgB,GAAI,UAAU,MAAM,KAAG,aAWnD,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,aAAa,GAAI,OAAO,aAAa,KAAG,MAEpD,CAAC;AAEF,wGAAwG;AACxG,eAAO,MAAM,cAAc,GAAU,UAAU,MAAM,KAAG,OAAO,CAAC,aAAa,CAU5E,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State directory resolution and layout (ARCHITECTURE.md §3).
|
|
3
|
+
*
|
|
4
|
+
* Default `~/.appduct/`, overridable with `APPDUCT_STATE_DIR` (tests rely on the
|
|
5
|
+
* override). Created lazily with mode `0700`.
|
|
6
|
+
*/
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { mkdir, chmod } from "node:fs/promises";
|
|
10
|
+
/** Resolves the state directory: explicit `override`, else `APPDUCT_STATE_DIR`, else `~/.appduct`. */
|
|
11
|
+
export const resolveStateDir = (override) => {
|
|
12
|
+
return override ?? process.env.APPDUCT_STATE_DIR ?? join(homedir(), ".appduct");
|
|
13
|
+
};
|
|
14
|
+
export const getStateDirPaths = (stateDir) => {
|
|
15
|
+
return {
|
|
16
|
+
root: stateDir,
|
|
17
|
+
socketPath: join(stateDir, "daemon.sock"),
|
|
18
|
+
pidFilePath: join(stateDir, "daemon.pid"),
|
|
19
|
+
logFilePath: join(stateDir, "daemon.log"),
|
|
20
|
+
keyPath: join(stateDir, "key.pem"),
|
|
21
|
+
configPath: join(stateDir, "config.json"),
|
|
22
|
+
auditDir: join(stateDir, "audit"),
|
|
23
|
+
spawnLockPath: join(stateDir, "daemon.spawn.lock"),
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Single seam for the control-socket address. Today this is always the UDS path; a Windows
|
|
28
|
+
* named-pipe implementation (`\\.\pipe\appduct-<user>`) plugs in here without touching any
|
|
29
|
+
* caller (ARCHITECTURE.md §13 — best-effort, not blocking v2.0).
|
|
30
|
+
*/
|
|
31
|
+
export const getSocketPath = (paths) => {
|
|
32
|
+
return paths.socketPath;
|
|
33
|
+
};
|
|
34
|
+
/** Creates the state dir (and `audit/`) with mode `0700` if missing; tightens the mode if it exists. */
|
|
35
|
+
export const ensureStateDir = async (stateDir) => {
|
|
36
|
+
const paths = getStateDirPaths(stateDir);
|
|
37
|
+
await mkdir(paths.auditDir, { recursive: true });
|
|
38
|
+
await chmod(paths.root, 0o700);
|
|
39
|
+
// `mkdir` honors the process umask (commonly 0o022, yielding drwxr-xr-x); tighten explicitly so
|
|
40
|
+
// `audit/` matches every other path under the state dir (ARCHITECTURE.md §3).
|
|
41
|
+
await chmod(paths.auditDir, 0o700);
|
|
42
|
+
return paths;
|
|
43
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Injectable timer seam (LOOP.md: "use injectable clock/timer seams for tests"). Every daemon
|
|
3
|
+
* module that schedules a TTL/grace/pre-claim/keepalive timer takes a {@link TimerFns} so tests
|
|
4
|
+
* can substitute an implementation that fires deterministically instead of waiting on real wall
|
|
5
|
+
* time, and so every timer this package creates can be traced back to a single seam for cleanup.
|
|
6
|
+
*/
|
|
7
|
+
export type TimerHandle = ReturnType<typeof setTimeout>;
|
|
8
|
+
export type IntervalHandle = ReturnType<typeof setInterval>;
|
|
9
|
+
export type TimerFns = {
|
|
10
|
+
setTimeout: (callback: () => void, ms: number) => TimerHandle;
|
|
11
|
+
clearTimeout: (handle: TimerHandle) => void;
|
|
12
|
+
setInterval: (callback: () => void, ms: number) => IntervalHandle;
|
|
13
|
+
clearInterval: (handle: IntervalHandle) => void;
|
|
14
|
+
};
|
|
15
|
+
export declare const systemTimers: TimerFns;
|
|
16
|
+
//# sourceMappingURL=timers.d.ts.map
|