@anhlp/alp-protocol 0.0.0-stage → 0.0.1-beta.3
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 +202 -0
- package/NOTICE +7 -0
- package/README.md +16 -2
- package/conformance/cases.json +951 -0
- package/conformance/run.d.mts +32 -0
- package/conformance/run.mjs +403 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +0 -0
- package/dist/contract-v1.d.ts +5113 -0
- package/dist/contract-v1.js +5 -0
- package/dist/contract.d.ts +25 -0
- package/dist/contract.js +28 -0
- package/dist/dto.d.ts +104 -0
- package/dist/dto.js +0 -0
- package/dist/events.d.ts +313 -0
- package/dist/events.js +0 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2 -0
- package/dist/public.d.ts +1 -0
- package/dist/public.js +2 -0
- package/dist/rpc.d.ts +570 -0
- package/dist/rpc.js +0 -0
- package/dist/session.d.ts +145 -0
- package/dist/session.js +0 -0
- package/dist/validate.d.ts +12 -0
- package/dist/validate.js +151 -0
- package/dist/wire.d.ts +80 -0
- package/dist/wire.js +62 -0
- package/fixtures/ipc-v1.jsonl +4 -0
- package/fixtures/v1/cases.json +3919 -0
- package/package.json +127 -4
- package/schema/ipc-v1.json +2335 -0
- package/schema/methods-v1.json +4757 -0
- package/src/connection.ts +9 -0
- package/src/contract-v1.js +2 -0
- package/src/contract.js +44 -0
- package/src/dto.ts +78 -0
- package/src/events.ts +181 -0
- package/src/index.ts +6 -0
- package/src/public.js +2 -0
- package/src/rpc.ts +113 -0
- package/src/session.ts +97 -0
- package/src/validate.js +115 -0
- package/src/wire.js +87 -0
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** Portable JSON-RPC structures; implementations and provider SDKs stay outside protocol. */
|
|
2
|
+
import type { RuntimeKind, Pin } from './dto.js';
|
|
3
|
+
import type { AlpError, Envelope, SessionSnapshot, TreeStatus, UserQuestion } from './events.js';
|
|
4
|
+
import type { PauseState, PromptContent, RecallAnswer, SessionPreview, SessionSpec, SessionSummary } from './session.js';
|
|
5
|
+
import type { EntryName, LanguageSetting, LibraryEntryRow, LibraryKind, LibraryScope, LibrarySource, TaskRow, TeamRow } from './dto.js';
|
|
6
|
+
|
|
7
|
+
export type RpcId = string | number | null;
|
|
8
|
+
export type RpcErrorData = { code: number; message: string; data?: unknown };
|
|
9
|
+
export type RpcRequest = { jsonrpc: '2.0'; id?: RpcId; method: string; params?: Record<string, unknown> };
|
|
10
|
+
export type RpcResponse = { jsonrpc: '2.0'; id: RpcId } & ({ result: unknown; error?: never } | { error: RpcErrorData; result?: never });
|
|
11
|
+
export type EventNotification = { jsonrpc: '2.0'; method: 'event'; params: Envelope };
|
|
12
|
+
|
|
13
|
+
/** Names a daemon may list in its daemon.hello result (schema/methods-v1.json `capabilities`). */
|
|
14
|
+
export type Capability = 'attach.after' | 'prompt.receipts' | 'extension.methods' | 'team' | 'task' | 'library' | 'library.project.write' | 'settings.language' | 'session.list.page' | 'question.watch' | 'question.watch.turns' | 'workspace.files' | 'workspace.files.read' | 'prompt.images';
|
|
15
|
+
|
|
16
|
+
/** `data.reason` values alpd sets on errors (docs/protocol/alp-json-rpc-v1.md §5.3). */
|
|
17
|
+
export type ErrorReason =
|
|
18
|
+
| 'hello_required' | 'protocol_version' | 'invalid_params' | 'invalid_id' | 'batch_unsupported' | 'frame_too_large'
|
|
19
|
+
| 'response_too_large' | 'persistence_failed' | 'outcome_unknown' | 'key_conflict'
|
|
20
|
+
// Project, task and library methods (docs/protocol/alp-json-rpc-v1.md §5.3).
|
|
21
|
+
| 'invalid_path' | 'invalid_scope' | 'invalid_entry' | 'secret_marker' | 'not_alp_project' | 'unknown_task' | 'not_found'
|
|
22
|
+
| 'library_policy' | 'unsafe_path' | 'revision_conflict' | 'task_state' | 'in_use' | 'busy' | 'unreadable'
|
|
23
|
+
// workspace.files.search (§12.3); workspace.files.list and read (§12.4).
|
|
24
|
+
| 'not_directory' | 'blocked_path' | 'ignored' | 'linked' | 'binary' | 'too_large';
|
|
25
|
+
|
|
26
|
+
type Empty = Record<string, never>;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Params and result of every v1 method, as schema/methods-v1.json defines them; the protocol tests
|
|
30
|
+
* type-check the golden fixtures against this map, so the two cannot drift. Results may carry
|
|
31
|
+
* properties this map does not name: clients ignore them.
|
|
32
|
+
*/
|
|
33
|
+
export type RpcMethodMap = {
|
|
34
|
+
'daemon.hello': { params: { protocolVersion: 1; client?: { name: string; version: string }; capabilities?: string[] }; result: { protocolVersion: 1; daemonVersion?: string; pid?: number; /** The daemon host's machine name (os.hostname()), a label for clients. */ hostname?: string; promptImageLimits?: { maxCount: number; maxBytes: number; maxTotalBytes: number }; capabilities?: string[] } };
|
|
35
|
+
'daemon.status': {
|
|
36
|
+
params: Empty;
|
|
37
|
+
result: {
|
|
38
|
+
pid: number; startedAt: string; version: string; protocolVersion: number; sessions: number; questions: number;
|
|
39
|
+
previousExit?: { kind: 'clean' | 'crash'; at?: string };
|
|
40
|
+
persistence?: Record<string, unknown>;
|
|
41
|
+
health: { live: boolean; ready: boolean; checks: Array<{ id: string; ok: boolean; code?: string; message?: string }> };
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
'daemon.shutdown': { params: Empty; result: Empty };
|
|
45
|
+
'daemon.pause': { params: { runtime?: RuntimeKind; now?: boolean; reason?: string }; result: PauseState };
|
|
46
|
+
'daemon.resume': { params: { runtime?: RuntimeKind }; result: PauseState };
|
|
47
|
+
'daemon.pauses': { params: Empty; result: PauseState };
|
|
48
|
+
'daemon.usage': { params: { refresh?: boolean }; result: { providers: Array<Record<string, unknown>> } };
|
|
49
|
+
'catalog.get': { params: Empty; result: { models: Array<Record<string, unknown>>; modes: Array<Record<string, unknown>>; thinkingOptions?: unknown; defaultModel: string } };
|
|
50
|
+
'assignment.recall': { params: { assignmentId?: string; taskId?: string; projectRoot?: string; question: string }; result: RecallAnswer };
|
|
51
|
+
'board.list': { params: { projectRoot: string }; result: { pins: Pin[] } };
|
|
52
|
+
'question.list': { params: { projectRoot?: string }; result: { questions: UserQuestion[] } };
|
|
53
|
+
/** Capability `question.watch`: the waiting questions now; then this connection is told of every tree's `question` and `question.resolved` (of `projectRoot` when given). `turns` (capability `question.watch.turns`): also every root's `turn.ended`. */
|
|
54
|
+
'question.watch': { params: { projectRoot?: string; turns?: boolean }; result: { questions: UserQuestion[] } };
|
|
55
|
+
/** Capability `workspace.files`: files under `projectRoot` whose relative path matches `query`, best first; read-only, links not followed. */
|
|
56
|
+
'workspace.files.search': { params: { projectRoot: string; query?: string; limit?: number }; result: { files: Array<{ path: string }>; truncated?: boolean } };
|
|
57
|
+
/** Capability `workspace.files.read`: the entries of a directory under `projectRoot` (its root when `path` is left out); links, blocked and (unless `includeIgnored`) .gitignore'd entries left out. */
|
|
58
|
+
'workspace.files.list': { params: { projectRoot: string; path?: string; includeIgnored?: boolean }; result: { entries: Array<{ name: string; path: string; kind: 'file' | 'directory'; size?: number; modifiedAt?: string }>; truncated?: boolean } };
|
|
59
|
+
/** Capability `workspace.files.read`: a UTF-8 text file under `projectRoot`, at most 1 MiB; read-only, links not followed. */
|
|
60
|
+
'workspace.files.read': { params: { projectRoot: string; path: string; includeIgnored?: boolean }; result: { path: string; content: string; size: number; modifiedAt?: string } };
|
|
61
|
+
'question.answer': { params: { questionId: string; text?: string; dismiss?: boolean; reason?: string }; result: { questionId: string } };
|
|
62
|
+
'session.create': {
|
|
63
|
+
params: { sessionId?: string; spec: SessionSpec; history?: 'skip' | 'replay'; delegation?: boolean; resume?: boolean };
|
|
64
|
+
result: { session: SessionSnapshot; attached: boolean; resumed?: true; durable?: boolean };
|
|
65
|
+
};
|
|
66
|
+
'session.preview': { params: { spec: SessionSpec }; result: { preview: SessionPreview } };
|
|
67
|
+
'session.attach': { params: { sessionId: string; replay?: boolean; after?: number }; result: { session: SessionSummary; cursor: number; reset?: true } };
|
|
68
|
+
'session.release': { params: { sessionId: string }; result: { closed: boolean } };
|
|
69
|
+
'session.prompt': {
|
|
70
|
+
params: { sessionId: string; clientMessageId: string; delivery?: 'auto' | 'steer'; content: PromptContent };
|
|
71
|
+
/** `{}` from a daemon without prompt.receipts. */
|
|
72
|
+
result: { outcome?: 'accepted' | 'failed'; error?: AlpError; duplicate?: boolean; durable?: boolean };
|
|
73
|
+
};
|
|
74
|
+
'session.interrupt': { params: { sessionId: string }; result: Empty };
|
|
75
|
+
'session.configure': { params: { sessionId: string; mode?: string }; result: { session: SessionSnapshot } };
|
|
76
|
+
'session.close': { params: { sessionId: string }; result: Empty };
|
|
77
|
+
'session.rename': { params: { sessionId: string; title: string }; result: { session: SessionSummary } };
|
|
78
|
+
'session.archive': { params: { sessionId: string; archived?: boolean }; result: { session: SessionSummary } };
|
|
79
|
+
'session.get': { params: { sessionId: string }; result: { session: SessionSummary } };
|
|
80
|
+
/** `limit`, `cursor`, `query` and `projectRoots` with capability `session.list.page`: a page, and `nextCursor` while more follow. */
|
|
81
|
+
'session.list': { params: { projectRoot?: string; projectRoots?: string[]; rootsOnly?: boolean; includeClosed?: boolean; includeArchived?: boolean; limit?: number; cursor?: string; query?: string }; result: { sessions: SessionSummary[]; nextCursor?: string } };
|
|
82
|
+
'session.status': { params: { sessionId: string }; result: { status: TreeStatus } };
|
|
83
|
+
'session.message': { params: { sessionId: string; text: string }; result: Empty };
|
|
84
|
+
'session.log': { params: { sessionId: string }; result: { rootId: string; entries: Array<Record<string, unknown>> } };
|
|
85
|
+
'extension.register': { params: { method: string }; result: { registered: string } };
|
|
86
|
+
'extension.unregister': { params: { method: string }; result: { unregistered: string } };
|
|
87
|
+
'team.list': { params: { projectRoot?: string }; result: { projectRoot: string | null; teams: TeamRow[] } };
|
|
88
|
+
'task.list': { params: { projectRoot: string }; result: { projectRoot: string; tasks: TaskRow[]; unreadable: Array<{ file: string; error: string }>; revision: string } };
|
|
89
|
+
'task.create': { params: { projectRoot: string; title: string; priority?: number; requestId?: string }; result: { id: string; duplicate?: true } };
|
|
90
|
+
'task.update': { params: { projectRoot: string; id: string; action: 'close' | 'reopen'; note?: string; ifRev?: number }; result: { id: string; status: string; rev: number; landed?: string } };
|
|
91
|
+
'task.gate.resolve': { params: { projectRoot: string; id: string; gate: string; note?: string; ifRev?: number }; result: { id: string; status: string; rev: number } };
|
|
92
|
+
'library.list': { params: { kind: LibraryKind; projectRoot?: string }; result: { projectRoot: string | null; entries: LibraryEntryRow[] } };
|
|
93
|
+
'library.get': {
|
|
94
|
+
params: { kind: LibraryKind; name: EntryName; projectRoot?: string; scope?: LibraryScope };
|
|
95
|
+
/** `content` carries `{ $secret: 'keep' }` in place of each secret value; `secrets` lists their JSON Pointers. */
|
|
96
|
+
result: { kind: LibraryKind; name: string; source: LibrarySource; overrides?: LibrarySource; content: Record<string, unknown>; revision: string | null; usedBy: string[]; librarySkills?: string[]; secrets?: string[] };
|
|
97
|
+
};
|
|
98
|
+
'library.save': {
|
|
99
|
+
params: { kind: LibraryKind; name: EntryName; scope: LibraryScope; projectRoot?: string; policy?: 'project-data'; content: Record<string, unknown>; revision?: string | null; sync?: 'none' | 'known-projects' };
|
|
100
|
+
result: { revision: string | null; syncedProjects?: number; syncErrors?: string[] };
|
|
101
|
+
};
|
|
102
|
+
'library.delete': { params: { kind: LibraryKind; name: EntryName; scope: LibraryScope; projectRoot?: string; policy?: 'project-data'; revision?: string }; result: { removed: true; now?: 'builtin' | 'library' } };
|
|
103
|
+
'library.duplicate': { params: { kind: LibraryKind; from: EntryName; to: EntryName; scope: LibraryScope; projectRoot?: string; policy?: 'project-data' }; result: { revision: string | null } };
|
|
104
|
+
'library.rename': { params: { kind: LibraryKind; from: EntryName; to: EntryName; scope: LibraryScope; projectRoot?: string; policy?: 'project-data' }; result: { revision: string | null } };
|
|
105
|
+
'library.skills.set': { params: { agent: EntryName; skills: EntryName[] }; result: { skills: string[] } };
|
|
106
|
+
'library.models': { params: Empty; result: { models: Array<{ id: string; label: string; efforts: string[]; effort?: string }> } };
|
|
107
|
+
'settings.language.get': { params: Empty; result: LanguageSetting };
|
|
108
|
+
'settings.language.set': { params: { language: string | null }; result: LanguageSetting };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type RpcMethod = keyof RpcMethodMap;
|
|
112
|
+
export type RpcParams<M extends RpcMethod> = RpcMethodMap[M]['params'];
|
|
113
|
+
export type RpcResult<M extends RpcMethod> = RpcMethodMap[M]['result'];
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { RuntimeKind } from './dto.js';
|
|
2
|
+
import type { SessionSnapshot } from './events.js';
|
|
3
|
+
/** MCP servers a client adds to the agent's own; the agent's names win no collisions. */
|
|
4
|
+
export type HostMcpServer =
|
|
5
|
+
| { type: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
6
|
+
| { type: 'http' | 'sse'; url: string; headers?: Record<string, string> };
|
|
7
|
+
|
|
8
|
+
/** What a client asks for; resolveSession turns it into a launchable session. */
|
|
9
|
+
export type SessionSpec = {
|
|
10
|
+
cwd: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
/** Workflow selected for a new session; omitted uses settings or the restored snapshot. */
|
|
13
|
+
workflow?: string;
|
|
14
|
+
/** Native model, or runtime-prefixed (`codex:`/`claude:`, or `acp:<provider>[/<model>]`) to also choose the runtime. */
|
|
15
|
+
model?: string;
|
|
16
|
+
mode?: string;
|
|
17
|
+
thinking?: string;
|
|
18
|
+
systemPrompt?: string;
|
|
19
|
+
env?: Record<string, string>;
|
|
20
|
+
mcpServers?: Record<string, HostMcpServer>;
|
|
21
|
+
/** Keep the native thread so the session can be resumed. */
|
|
22
|
+
persist?: boolean;
|
|
23
|
+
/** Keep the native thread on disk though the session cannot be resumed: an assignment, which alp_recall may question later. */
|
|
24
|
+
keepThread?: boolean;
|
|
25
|
+
restore?: { agent: string; threadId: string; runtime?: string; model?: string; workflow?: { mode: string; maxPeers: number; supervisor?: boolean } };
|
|
26
|
+
/** Where the native harness works, when not the project root: an assignment's git worktree. ALP files are still read from cwd. */
|
|
27
|
+
workdir?: string;
|
|
28
|
+
/** Explicit shared-assignment write scope; workdir points here while ALP uses cwd for its project. */
|
|
29
|
+
writeRoot?: string;
|
|
30
|
+
/** The workdir is a disposable copy the session may write, though its mode is read-only for the requester's tree. */
|
|
31
|
+
copy?: boolean;
|
|
32
|
+
/** The requester's directory the copy mirrors. */
|
|
33
|
+
copyOf?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type OpenOptions = {
|
|
37
|
+
history?: 'replay' | 'skip';
|
|
38
|
+
/** False when the client cannot show child sessions; delegation is then refused in this tree. */
|
|
39
|
+
delegation?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** An image inline in a prompt (capability `prompt.images`): base64 data, never a path or URL; limits in PROMPT_IMAGES. */
|
|
43
|
+
export type PromptImage = { type: 'image'; mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; data: string; name?: string };
|
|
44
|
+
/** Text parts carry `text`; image parts (`type: 'image'`) carry PromptImage's fields. */
|
|
45
|
+
export type PromptContent = Array<{ type: string; text?: string; mimeType?: string; data?: string; name?: string }>;
|
|
46
|
+
|
|
47
|
+
export type PromptInput = {
|
|
48
|
+
clientMessageId: string;
|
|
49
|
+
/** auto starts a turn; steer adds to the running one. */
|
|
50
|
+
delivery: 'auto' | 'steer';
|
|
51
|
+
content: PromptContent;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A root before it opens: the team and main it would run with, for viewers that let the user choose first (ALPD §60). */
|
|
55
|
+
export type SessionPreview = {
|
|
56
|
+
teams: Array<{ id: string; label: string; description?: string }>;
|
|
57
|
+
/** The team the spec selects, else settings' workflow.mode, else Phở. */
|
|
58
|
+
team: string;
|
|
59
|
+
teamLabel?: string;
|
|
60
|
+
agent: string;
|
|
61
|
+
runtime: RuntimeKind;
|
|
62
|
+
model: string;
|
|
63
|
+
mode: string;
|
|
64
|
+
thinking: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** What became of one assignment an earlier alpd left running. */
|
|
68
|
+
export type RecoveryOutcome = { assignmentId: string; agent: string; outcome: 'resumed' | 'parked' | 'failed'; error?: string };
|
|
69
|
+
|
|
70
|
+
export type Pause = { since: string; by: string; reason: string; resetsAt?: string; /** When alpd last asked the runtime about the limit (ALPD §58). */ checkedAt?: string };
|
|
71
|
+
export type PauseState = {
|
|
72
|
+
all?: Pause;
|
|
73
|
+
runtimes: Partial<Record<RuntimeKind, Pause>>;
|
|
74
|
+
parked: Array<{ assignmentId: string; agent: string; runtime: RuntimeKind; rootId: string; reason: string; since: string }>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type RecallAnswer = { assignmentId: string; agent: string; taskId?: string; finishedAt: string; answer: string };
|
|
78
|
+
|
|
79
|
+
/** A session's lifecycle as alpd records it; neutral, so viewers and integrations share one union. */
|
|
80
|
+
export type SessionStatus = 'initializing' | 'idle' | 'running' | 'error' | 'closed';
|
|
81
|
+
|
|
82
|
+
/** The last failure recorded for a session (daemon store `SessionRecord.lastError`). */
|
|
83
|
+
export type SessionError = { code?: string; message: string };
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A session as clients list it: its last snapshot plus lifecycle (ALPD §18). This is the portable
|
|
87
|
+
* shape served by `session.list`; the daemon and every viewer agree on it, so it is the same type
|
|
88
|
+
* the daemon's store and server use rather than a lossy summary.
|
|
89
|
+
*/
|
|
90
|
+
export type SessionSummary = SessionSnapshot & {
|
|
91
|
+
status: SessionStatus;
|
|
92
|
+
title?: string;
|
|
93
|
+
archived?: boolean;
|
|
94
|
+
lastError?: SessionError;
|
|
95
|
+
createdAt?: string;
|
|
96
|
+
updatedAt?: string;
|
|
97
|
+
};
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dependency-free checker for the JSON Schema subset the protocol schemas use (draft-07:
|
|
3
|
+
* type, const, enum, properties, required, additionalProperties, items, minItems, maxItems, minimum, maximum,
|
|
4
|
+
* minLength, maxLength, pattern, oneOf, anyOf, not and local `#/$defs/...` references).
|
|
5
|
+
* Lengths count Unicode code points, as JSON Schema does. A property whose value is
|
|
6
|
+
* `undefined` counts as absent, so in-process callers behave as their JSON would.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @typedef {{ path: string, message: string }} SchemaIssue */
|
|
10
|
+
|
|
11
|
+
const MAX_ISSUES = 8;
|
|
12
|
+
|
|
13
|
+
/** @param {unknown} value */
|
|
14
|
+
function typeOf(value) {
|
|
15
|
+
if (value === null) return 'null';
|
|
16
|
+
if (Array.isArray(value)) return 'array';
|
|
17
|
+
if (typeof value === 'number') return !Number.isFinite(value) ? 'non-finite number' : Number.isInteger(value) ? 'integer' : 'number';
|
|
18
|
+
return typeof value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {string} want @param {string} have */
|
|
22
|
+
const typeMatches = (want, have) => want === have || (want === 'number' && have === 'integer');
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
25
|
+
function equal(a, b) {
|
|
26
|
+
if (a === b) return true;
|
|
27
|
+
if (typeOf(a) !== typeOf(b) || a === null || typeof a !== 'object') return false;
|
|
28
|
+
if (Array.isArray(a)) return a.length === /** @type {unknown[]} */ (b).length && a.every((item, index) => equal(item, /** @type {unknown[]} */ (b)[index]));
|
|
29
|
+
const left = Object.keys(a).filter(key => /** @type {any} */ (a)[key] !== undefined);
|
|
30
|
+
const right = Object.keys(/** @type {object} */ (b)).filter(key => /** @type {any} */ (b)[key] !== undefined);
|
|
31
|
+
return left.length === right.length && left.every(key => equal(/** @type {any} */ (a)[key], /** @type {any} */ (b)[key]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {any} root @param {string} ref */
|
|
35
|
+
function resolve(root, ref) {
|
|
36
|
+
if (!ref.startsWith('#/')) throw new Error(`Unsupported schema reference ${ref}`);
|
|
37
|
+
let target = root;
|
|
38
|
+
for (const part of ref.slice(2).split('/')) target = target?.[part.replaceAll('~1', '/').replaceAll('~0', '~')];
|
|
39
|
+
if (target === undefined) throw new Error(`Unresolved schema reference ${ref}`);
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {any} schema
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @param {string} at
|
|
47
|
+
* @param {any} root
|
|
48
|
+
* @param {SchemaIssue[]} issues
|
|
49
|
+
*/
|
|
50
|
+
function check(schema, value, at, root, issues) {
|
|
51
|
+
if (issues.length >= MAX_ISSUES || schema === true || schema === undefined) return;
|
|
52
|
+
if (schema === false) { issues.push({ path: at, message: 'is not allowed' }); return; }
|
|
53
|
+
if (schema.$ref) { check(resolve(root, schema.$ref), value, at, root, issues); if (Object.keys(schema).length === 1) return; }
|
|
54
|
+
const kind = typeOf(value);
|
|
55
|
+
if (schema.type !== undefined) {
|
|
56
|
+
const wanted = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
57
|
+
if (!wanted.some(/** @param {string} want */ want => typeMatches(want, kind))) { issues.push({ path: at, message: `must be ${wanted.join(' or ')}` }); return; }
|
|
58
|
+
}
|
|
59
|
+
if ('const' in schema && !equal(schema.const, value)) { issues.push({ path: at, message: `must be ${JSON.stringify(schema.const)}` }); return; }
|
|
60
|
+
if (schema.enum && !schema.enum.some(/** @param {unknown} option */ option => equal(option, value))) { issues.push({ path: at, message: `must be one of ${schema.enum.map(/** @param {unknown} option */ option => JSON.stringify(option)).join(', ')}` }); return; }
|
|
61
|
+
if (kind === 'integer' || kind === 'number') {
|
|
62
|
+
const number = /** @type {number} */ (value);
|
|
63
|
+
if (schema.minimum !== undefined && number < schema.minimum) issues.push({ path: at, message: `must be at least ${schema.minimum}` });
|
|
64
|
+
if (schema.maximum !== undefined && number > schema.maximum) issues.push({ path: at, message: `must be at most ${schema.maximum}` });
|
|
65
|
+
}
|
|
66
|
+
if (kind === 'string') {
|
|
67
|
+
const text = /** @type {string} */ (value);
|
|
68
|
+
let length = 0;
|
|
69
|
+
if (schema.minLength !== undefined || schema.maxLength !== undefined) for (const _point of text) length++;
|
|
70
|
+
if (schema.minLength !== undefined && length < schema.minLength) issues.push({ path: at, message: `must have at least ${schema.minLength} characters` });
|
|
71
|
+
if (schema.maxLength !== undefined && length > schema.maxLength) issues.push({ path: at, message: `must have at most ${schema.maxLength} characters` });
|
|
72
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(text)) issues.push({ path: at, message: `must match ${schema.pattern}` });
|
|
73
|
+
}
|
|
74
|
+
if (kind === 'array' && schema.minItems !== undefined && /** @type {unknown[]} */ (value).length < schema.minItems) issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
|
|
75
|
+
if (kind === 'array' && schema.maxItems !== undefined && /** @type {unknown[]} */ (value).length > schema.maxItems) issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
|
|
76
|
+
if (kind === 'array' && schema.items !== undefined) {
|
|
77
|
+
const array = /** @type {unknown[]} */ (value);
|
|
78
|
+
for (let index = 0; index < array.length && issues.length < MAX_ISSUES; index++) check(schema.items, array[index] === undefined ? null : array[index], `${at}/${index}`, root, issues);
|
|
79
|
+
}
|
|
80
|
+
if (kind === 'object') {
|
|
81
|
+
const object = /** @type {Record<string, unknown>} */ (value);
|
|
82
|
+
const properties = schema.properties ?? {};
|
|
83
|
+
for (const name of schema.required ?? []) if (object[name] === undefined) issues.push({ path: `${at}/${name}`, message: 'is required' });
|
|
84
|
+
for (const [name, child] of Object.entries(object)) {
|
|
85
|
+
if (child === undefined) continue;
|
|
86
|
+
if (Object.hasOwn(properties, name)) check(properties[name], child, `${at}/${name}`, root, issues);
|
|
87
|
+
else if (schema.additionalProperties !== undefined) check(schema.additionalProperties, child, `${at}/${name}`, root, issues);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (schema.not !== undefined) {
|
|
91
|
+
/** @type {SchemaIssue[]} */
|
|
92
|
+
const inner = [];
|
|
93
|
+
check(schema.not, value, at, root, inner);
|
|
94
|
+
if (!inner.length) issues.push({ path: at, message: 'matches a schema it must not match' });
|
|
95
|
+
}
|
|
96
|
+
for (const [keyword, exactlyOne] of /** @type {const} */ ([['anyOf', false], ['oneOf', true]])) {
|
|
97
|
+
if (!schema[keyword]) continue;
|
|
98
|
+
const passing = schema[keyword].filter(/** @param {any} option */ option => { /** @type {SchemaIssue[]} */ const inner = []; check(option, value, at, root, inner); return !inner.length; }).length;
|
|
99
|
+
if (exactlyOne ? passing !== 1 : passing === 0) issues.push({ path: at, message: exactlyOne && passing > 1 ? 'matches more than one allowed shape' : 'matches none of the allowed shapes' });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Checks `value` against `schema`, resolving `#/$defs/...` against `root`.
|
|
105
|
+
* @param {any} schema
|
|
106
|
+
* @param {unknown} value
|
|
107
|
+
* @param {any} [root]
|
|
108
|
+
* @returns {SchemaIssue[]} empty when the value conforms
|
|
109
|
+
*/
|
|
110
|
+
export function validateSchema(schema, value, root = schema) {
|
|
111
|
+
/** @type {SchemaIssue[]} */
|
|
112
|
+
const issues = [];
|
|
113
|
+
check(schema, value, '', root, issues);
|
|
114
|
+
return issues;
|
|
115
|
+
}
|
package/src/wire.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Wire protocol of alpd (plans/reference/ALPD.md §3). Bump only for incompatible changes. */
|
|
2
|
+
export const PROTOCOL_VERSION = 1;
|
|
3
|
+
export const MAX_FRAME = 16 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export class AlpRpcError extends Error {
|
|
6
|
+
constructor(error) {
|
|
7
|
+
super(error?.message ?? 'ALP daemon request failed');
|
|
8
|
+
this.code = error?.code;
|
|
9
|
+
this.data = error?.data;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const ERROR = Object.freeze({ failed: 1000, notFound: 1001, forbidden: 1003, conflict: 1004, unavailable: 1005, protocol: 1006 });
|
|
14
|
+
/** The JSON-RPC 2.0 codes alpd answers with (docs/protocol/alp-json-rpc-v1.md §5). */
|
|
15
|
+
export const RPC_ERROR = Object.freeze({ parseError: -32700, invalidRequest: -32600, methodNotFound: -32601, invalidParams: -32602 });
|
|
16
|
+
/**
|
|
17
|
+
* Features a daemon lists in its daemon.hello result. A client uses a feature only when listed;
|
|
18
|
+
* a daemon that lists none (one older than this list) has none of them. Unknown names are ignored.
|
|
19
|
+
*/
|
|
20
|
+
export const CAPABILITY = Object.freeze({ attachAfter: 'attach.after', promptReceipts: 'prompt.receipts', extensionMethods: 'extension.methods', team: 'team', task: 'task', library: 'library', libraryProjectWrite: 'library.project.write', settingsLanguage: 'settings.language', sessionListPage: 'session.list.page', questionWatch: 'question.watch', questionWatchTurns: 'question.watch.turns', workspaceFiles: 'workspace.files', workspaceFilesRead: 'workspace.files.read', promptImages: 'prompt.images' });
|
|
21
|
+
/**
|
|
22
|
+
* What alpd takes as images in one session.prompt (capability `prompt.images`): inline base64 of
|
|
23
|
+
* these types only, never a path or URL; at most `maxCount` of them, each at most `maxBytes`
|
|
24
|
+
* decoded, and `maxTotalBytes` together, so their base64 fits one MAX_FRAME. A gateway may allow
|
|
25
|
+
* less (docs/alp/remote-gateway.md).
|
|
26
|
+
*/
|
|
27
|
+
export const PROMPT_IMAGES = Object.freeze({ mimeTypes: Object.freeze(['image/png', 'image/jpeg', 'image/gif', 'image/webp']), maxCount: 8, maxBytes: 5 * 1024 * 1024, maxTotalBytes: 11 * 1024 * 1024 });
|
|
28
|
+
/** Decoded bytes of base64 `data` (padding aware), without decoding it. @param {string} data */
|
|
29
|
+
export const base64Bytes = data => Math.floor(data.length * 3 / 4) - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
|
|
30
|
+
export const RPC_METHODS = Object.freeze(["assignment.recall", "board.list", "catalog.get", "daemon.hello", "daemon.pause", "daemon.pauses", "daemon.resume", "daemon.shutdown", "daemon.status", "daemon.usage", "extension.register", "extension.unregister", "library.delete", "library.duplicate", "library.get", "library.list", "library.models", "library.rename", "library.save", "library.skills.set", "question.answer", "question.list", "question.watch", "session.archive", "session.attach", "session.close", "session.configure", "session.create", "session.get", "session.interrupt", "session.list", "session.log", "session.message", "session.preview", "session.prompt", "session.release", "session.rename", "session.status", "settings.language.get", "settings.language.set", "task.create", "task.gate.resolve", "task.list", "task.update", "team.list", "workspace.files.list", "workspace.files.read", "workspace.files.search"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Limits of workspace.files.list and workspace.files.read (capability `workspace.files.read`,
|
|
34
|
+
* docs/protocol/alp-json-rpc-v1.md §12.4): a file of at most `maxReadBytes`, a listing of at most
|
|
35
|
+
* `maxListEntries` entries, a relative path of at most `maxPathLength` characters.
|
|
36
|
+
*/
|
|
37
|
+
export const WORKSPACE_FILES = Object.freeze({ maxReadBytes: 1024 * 1024, maxListEntries: 1000, maxPathLength: 4096 });
|
|
38
|
+
|
|
39
|
+
/** Directories whose whole content is refused: Git's, ALP's own configuration (its library overrides hold env values), and key and cloud-credential stores. */
|
|
40
|
+
const BLOCKED_DIRECTORIES = new Set(['.git', '.alp', '.ssh', '.gnupg', '.aws', '.azure', '.kube', '.docker', 'gcloud']);
|
|
41
|
+
/** Files that hold credentials by convention. */
|
|
42
|
+
const BLOCKED_NAMES = new Set(['.netrc', '_netrc', '.npmrc', '.yarnrc.yml', '.pypirc', '.pgpass', '.htpasswd', '.git-credentials', '.dockercfg', '.boto', '.s3cfg', '.mcp.json', 'auth.json', 'kubeconfig', 'settings.local.json', '.vault-token', 'application_default_credentials.json', 'token.json']);
|
|
43
|
+
/** Credential files known by their directory too: GitHub CLI's token file. */
|
|
44
|
+
const BLOCKED_PAIRS = new Set(['gh/hosts.yml']);
|
|
45
|
+
/** Names of credential files before their first dot (a leading dot aside): credentials.json, .credentials.json, secrets.yaml, a secrets/ directory. */
|
|
46
|
+
const BLOCKED_STEMS = new Set(['credential', 'credentials', 'secret', 'secrets']);
|
|
47
|
+
/**
|
|
48
|
+
* Source code is not a credential store: src/credentials.ts, lib/secrets.py stay readable (the stem
|
|
49
|
+
* rule only; never a data or config format such as json, yaml, toml, ini, xml, txt). alpd's
|
|
50
|
+
* private-key check still applies to them.
|
|
51
|
+
*/
|
|
52
|
+
const SOURCE_EXTENSIONS = new Set(['ts', 'tsx', 'mts', 'cts', 'js', 'jsx', 'mjs', 'cjs', 'py', 'go', 'rs', 'java', 'kt', 'kts', 'rb', 'php', 'cs', 'swift', 'c', 'cc', 'cpp', 'h', 'hpp', 'm', 'mm', 'scala', 'dart', 'vue', 'svelte']);
|
|
53
|
+
/** Key, certificate-store, keyring and Terraform-state extensions. */
|
|
54
|
+
const BLOCKED_EXTENSIONS = new Set(['pem', 'key', 'p12', 'pfx', 'p8', 'jks', 'keystore', 'ppk', 'der', 'gpg', 'pgp', 'asc', 'kdbx', 'ovpn', 'tfstate', 'tfvars']);
|
|
55
|
+
/** .env and every name starting with it (.env.local, .envrc); id_* (SSH keys: id_rsa, id_ed25519.pub, id_work); service-account keys; shell and REPL histories; Terraform state backups. */
|
|
56
|
+
const BLOCKED_PATTERN = /^(?:\.env|id_|service[-_]?account)|_history$|\.tfstate\.backup$/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a path relative to a workspace names something workspace.files.list and
|
|
60
|
+
* workspace.files.read never show, whatever the grant: a segment that is a blocked directory, or
|
|
61
|
+
* any segment (file or directory) named as a secret is (BLOCKED_* above). Compared without case,
|
|
62
|
+
* as macOS and Windows file systems name files. alpd and the gateway both check it; alpd also
|
|
63
|
+
* refuses a file whose content holds a private key, whatever its name.
|
|
64
|
+
* @param {string} relative a path relative to the workspace, '/'-separated
|
|
65
|
+
*/
|
|
66
|
+
export function blockedFilePath(relative) {
|
|
67
|
+
let previous = '';
|
|
68
|
+
for (const segment of String(relative).toLowerCase().split(/[\\/]/)) {
|
|
69
|
+
if (!segment) continue;
|
|
70
|
+
if (BLOCKED_PAIRS.has(`${previous}/${segment}`)) return true;
|
|
71
|
+
previous = segment;
|
|
72
|
+
if (BLOCKED_DIRECTORIES.has(segment) || BLOCKED_NAMES.has(segment) || BLOCKED_PATTERN.test(segment)) return true;
|
|
73
|
+
const dot = segment.lastIndexOf('.');
|
|
74
|
+
const extension = dot >= 0 ? segment.slice(dot + 1) : '';
|
|
75
|
+
if (BLOCKED_EXTENSIONS.has(extension)) return true;
|
|
76
|
+
// Any dotted part of the name but its extension: secrets.yaml, prod.secrets.yaml, .credentials.json.
|
|
77
|
+
const parts = segment.replace(/^\.+/, '').split('.');
|
|
78
|
+
if (parts.length > 1) parts.pop();
|
|
79
|
+
if (parts.some(part => BLOCKED_STEMS.has(part)) && !SOURCE_EXTENSIONS.has(extension)) return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** UTF-8 socket writers use one newline-terminated JSON value per frame. @param {unknown} value */
|
|
85
|
+
export function encodeFrame(value) { return `${JSON.stringify(value)}\n`; }
|
|
86
|
+
/** Parse one complete frame. Buffering and failure handling belong to the socket owner. @param {string} line @returns {unknown} */
|
|
87
|
+
export function decodeFrame(line) { return JSON.parse(line); }
|