@ai-wayfinding/client 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -14
- package/dist/cli.js +64 -13
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +74 -21
- package/dist/journey.js +6 -2
- package/dist/network.d.ts +5 -0
- package/dist/network.js +35 -0
- package/dist/state.d.ts +36 -0
- package/dist/state.js +104 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -4,21 +4,24 @@ The `wayfinding` command gives an agent access to one encrypted journey after a
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
-
Node.js 22 or newer is required.
|
|
7
|
+
Node.js 22 or newer is required. Install from npm:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
|
|
11
|
-
curl -fsSLO "$R/ai-wayfinding-core.tgz"
|
|
12
|
-
curl -fsSLO "$R/ai-wayfinding-client.tgz"
|
|
13
|
-
npm install -g ./ai-wayfinding-core.tgz ./ai-wayfinding-client.tgz
|
|
10
|
+
npm install -g @ai-wayfinding/client
|
|
14
11
|
wayfinding --help
|
|
15
12
|
```
|
|
16
13
|
|
|
17
|
-
|
|
14
|
+
If your system does not allow a global install, install to a folder you control:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npm install -g --prefix "$HOME/.npm-global" @ai-wayfinding/client
|
|
18
|
+
export PATH="$HOME/.npm-global/bin:$PATH"
|
|
19
|
+
wayfinding --help
|
|
20
|
+
```
|
|
18
21
|
|
|
19
22
|
### Build from this repository
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
From a clone of this repository:
|
|
22
25
|
|
|
23
26
|
```sh
|
|
24
27
|
npm ci
|
|
@@ -26,12 +29,12 @@ npm run build -w packages/core
|
|
|
26
29
|
npm run build -w packages/client
|
|
27
30
|
npm pack -w packages/core
|
|
28
31
|
npm pack -w packages/client
|
|
29
|
-
# Install both local tarballs together.
|
|
32
|
+
# Install both local tarballs together.
|
|
30
33
|
npm install -g ./ai-wayfinding-core-*.tgz ./ai-wayfinding-client-*.tgz
|
|
31
34
|
wayfinding --help
|
|
32
35
|
```
|
|
33
36
|
|
|
34
|
-
If a global install is not wanted, run `node packages/client/dist/cli.js --help` from the clone after building. A GitHub URL is not a reliable npm install target for one package inside this workspace; installing the root does not install the client binary.
|
|
37
|
+
If a global install is not wanted, run `node packages/client/dist/cli.js --help` from the clone after building. A GitHub URL is not a reliable npm install target for one package inside this workspace; installing the root does not install the client binary.
|
|
35
38
|
|
|
36
39
|
## Connect to a journey
|
|
37
40
|
|
|
@@ -40,9 +43,23 @@ wayfinding connect <journey-id> --scope read
|
|
|
40
43
|
wayfinding connect <journey-id> --scope readwrite --remember
|
|
41
44
|
```
|
|
42
45
|
|
|
43
|
-
The journey server defaults to `https://app.wayfinding.support`; use `--server https://your-server` if needed. The command creates a new age encryption identity and signing key **in memory**, asks the server for approval, and prints a link and a six-digit code. Open the link in your browser, check that the code matches, choose the approved access and time, then confirm with your passkey. The command waits until approval, expiry, or lockout. Approval is valid for at most 8 hours without remembering, or 90 days when remembered. The person must choose remembered access in the approval screen before keys are saved; the client refuses expired keys.
|
|
46
|
+
The journey server defaults to `https://app.wayfinding.support`; use `--server https://your-server` if needed. The single-process command creates a new age encryption identity and signing key **in memory**, asks the server for approval, and prints a link and a six-digit code. Open the link in your browser, check that the code matches, choose the approved access and time, then confirm with your passkey. The command waits until approval, expiry, or lockout. Approval is valid for at most 8 hours without remembering, or 90 days when remembered. The person must choose remembered access in the approval screen before keys are saved; the client refuses expired keys.
|
|
47
|
+
|
|
48
|
+
Without `--remember`, the one-shot `connect` command does not save keys. It closes the connection when it exits. Start `wayfinding mcp --connect <journey-id>` to keep an in-memory connection for that MCP process, or pass `--journey <journey-id>` to an individual CLI command to ask for fresh approval on each invocation. There is no background daemon or invisible persistent login.
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
### Connect when each command runs in a fresh process
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
wayfinding connect YOUR_JOURNEY_ID --scope read --name "Research assistant" --state "$HOME/wayfinding-agent.json" --no-wait
|
|
54
|
+
# Open the printed link in a browser and approve after checking the six-digit code.
|
|
55
|
+
wayfinding connect --state "$HOME/wayfinding-agent.json" --wait --timeout 600
|
|
56
|
+
wayfinding list --state "$HOME/wayfinding-agent.json"
|
|
57
|
+
wayfinding mcp --state "$HOME/wayfinding-agent.json"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`--no-wait` creates a pending request, writes its keys, request ID, server and expiry to a **0600** state file, then prints the link and code and exits. It refuses an existing file (especially one without private permissions). `--wait` uses that file to poll and can be safely retried after a timeout; it becomes an approved session file after approval. The file expires with the approved session, never later than eight hours after creation. Expired files are deleted and refused. Keep the file private; it contains unencrypted keys. Delete it when finished. This is separate from `--remember`, which still uses the keychain or an encrypted key folder. Use `--state FILE` on `add`, `import`, `list`, `search`, `show`, `comment`, `comments`, `status`, or `mcp`.
|
|
61
|
+
|
|
62
|
+
Pass `--json` on either connect step to print a single JSON object with `link`, `code`, `requestId`, `expiresAt` (ISO 8601), and `status`. Exit status **0** means the request was created or access was approved; **2** means approval is still pending after `--wait` times out; **3** means expired; **4** means denied or locked out; **5** means the server or proxy cannot be reached. Other errors use exit status 1. The client automatically uses `HTTPS_PROXY`, `https_proxy`, or `HTTP_PROXY` when set. If a connection fails, it reports the underlying cause and advises checking the proxy or asking the workspace admin to allow `app.wayfinding.support`.
|
|
46
63
|
|
|
47
64
|
With `--remember`, agent keys go to macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). Unlock/install that service first. If the OS keychain is unavailable or the platform is unsupported, the command refuses instead of storing unencrypted keys. Windows users can use `--key-folder <path>` with a passphrase; there is no Windows Credential Manager adapter. On every OS, `--key-folder <path> --remember` stores an age-passphrase-encrypted `agent.age` in a private folder (0700 folder, 0600 file on Unix). Set `WAYFINDING_PASSPHRASE` or enter it at the terminal when prompted; the passphrase is never stored. Do not choose a shared folder.
|
|
48
65
|
|
|
@@ -64,11 +81,11 @@ wayfinding comments <item-id>
|
|
|
64
81
|
|
|
65
82
|
Markdown imports retain simple front matter fields `title`, `type` (or `itemType`), `tags` (comma-separated or inline array), `created`, `resourceKind`, and `sharedFrom`; the remaining Markdown is the body. Files must have `.md` extensions. The agent is always marked as the author, regardless of input front matter. For a directory, Markdown files are read recursively.
|
|
66
83
|
|
|
67
|
-
Each read checks the full signed journey membership log. Before a write, the client checks that log again, confirms this agent is still an active read-write member and that its version meets the journey minimum, then encrypts the item or comment locally. Server requests are signed with the method, path and query, body digest, timestamp, and fresh nonce. If access ends, the client says so and stops.
|
|
84
|
+
Each read checks the full signed journey membership log. Before a write, the client checks that log again, confirms this agent is still an active read-write member and that its version meets the journey minimum, then encrypts the item or comment locally. Server requests are signed with the method, path and query, body digest, timestamp, and fresh nonce. If access ends, the client says so and stops. Decrypted items remain in memory for the process lifetime; keys are also stored in a file only when you explicitly use `--state`.
|
|
68
85
|
|
|
69
86
|
### Optional encrypted-data cache
|
|
70
87
|
|
|
71
|
-
A remembered session uses a local cache by default. An in-memory connection uses no cache by default. Pass `--cache` to enable it for a one-shot command or `--no-cache` to disable it. Under the OS cache directory (macOS: `~/Library/Caches`; Linux: `$XDG_CACHE_HOME` or `~/.cache`; Windows: `%LOCALAPPDATA%`), the client stores **only ciphertext envelopes and the head of a freshly verified signed log**. The journey key, secret signing key, item titles, and item bodies never go to this cache. On Unix, cache folders are 0700 and files 0600. The client refreshes records by sequence number but verifies the full signed log again before every use. Disable the cache on a shared device.
|
|
88
|
+
A remembered session uses a local cache by default. An in-memory connection uses no cache by default. Pass `--cache` to enable it for a one-shot command or `--no-cache` to disable it. Under the OS cache directory (macOS: `~/Library/Caches`; Linux: `$XDG_CACHE_HOME` or `~/.cache`; Windows: `%LOCALAPPDATA%`), the client stores **only ciphertext envelopes and the head of a freshly verified signed log**. The journey key, secret signing key, item titles, and item bodies never go to this cache. A `--state` file, when requested, is separate from this encrypted-data cache. On Unix, cache folders are 0700 and files 0600. The client refreshes records by sequence number but verifies the full signed log again before every use. Disable the cache on a shared device.
|
|
72
89
|
|
|
73
90
|
## Connect an MCP tool host
|
|
74
91
|
|
|
@@ -85,6 +102,19 @@ The MCP server exposes `add`, `import`, `list`, `search`, `show`, `comment`, `co
|
|
|
85
102
|
}
|
|
86
103
|
```
|
|
87
104
|
|
|
88
|
-
For Claude Desktop,
|
|
105
|
+
For Claude Desktop, after completing the two-step file-backed connection above, add this local MCP server to `claude_desktop_config.json` (replace the path with your private state file):
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"wayfinding": {
|
|
111
|
+
"command": "wayfinding",
|
|
112
|
+
"args": ["mcp", "--state", "/absolute/path/to/wayfinding-agent.json"]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The earlier `--connect` example instead asks for approval when MCP starts. For Claude Code, use `claude mcp add wayfinding -- wayfinding mcp --connect YOUR_JOURNEY_ID`. For Codex, use `codex mcp add wayfinding -- wayfinding mcp --connect YOUR_JOURNEY_ID`. Use the absolute path to the built `cli.js` with `node` if `wayfinding` is not on the host's PATH. When the MCP process begins, the person sees the link and code in the host's standard-error log; approve it before the MCP connection finishes. Some hosts hide stderr: run `wayfinding connect <journey-id> --remember` in a terminal first, then configure `wayfinding mcp` without `--connect`.
|
|
89
119
|
|
|
90
120
|
The person manages other members and any key rotation in the browser. An agent cannot approve itself, change someone's scope, add or remove members, or rotate journey keys. If the person previously rotated the journey key, the current approval API may not provide this agent the old epoch wraps needed to verify *all* earlier history; this client stops with a missing-key message rather than writing without verification.
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { stdin, stderr } from 'node:process';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
-
import { connectJourney } from './connection.js';
|
|
5
|
+
import { connectJourney, requestConnection, resumeConnection } from './connection.js';
|
|
6
|
+
import { ExpiredStateError, loadState } from './state.js';
|
|
6
7
|
import { importMarkdown } from './import.js';
|
|
7
8
|
import { JourneyClient } from './journey.js';
|
|
8
9
|
import { runMcp } from './mcp.js';
|
|
@@ -10,6 +11,8 @@ import { forgetRemembered, loadRemembered } from './storage.js';
|
|
|
10
11
|
const help = `wayfinding — read and write an approved journey
|
|
11
12
|
|
|
12
13
|
wayfinding connect <journey-id> [--name "Agent name"] [--scope read|readwrite] [--remember] [--server https://app.wayfinding.support] [--key-folder PATH]
|
|
14
|
+
wayfinding connect <journey-id> --state FILE --no-wait [--json]
|
|
15
|
+
wayfinding connect --state FILE --wait [--timeout SECONDS] [--json]
|
|
13
16
|
wayfinding disconnect [--key-folder PATH]
|
|
14
17
|
wayfinding add --type TYPE --title TITLE --body TEXT [--tags a,b]
|
|
15
18
|
wayfinding import <file-or-folder>
|
|
@@ -21,11 +24,12 @@ wayfinding comments <id>
|
|
|
21
24
|
wayfinding status
|
|
22
25
|
wayfinding mcp [--connect <journey-id>] [--name "Agent name"]
|
|
23
26
|
|
|
27
|
+
Use --state FILE with commands or mcp to use an approved file-backed session.
|
|
24
28
|
Use --journey <journey-id> with any one-shot command to ask for approval each time without remembering keys.
|
|
25
29
|
Use --cache to store only encrypted journey records and a verified log head; --no-cache turns it off.
|
|
26
30
|
Keys never go into the local cache. An agent cannot change journey membership or access.`;
|
|
27
|
-
const valueFlags = new Set(['--scope', '--name', '--server', '--key-folder', '--journey', '--connect', '--type', '--title', '--body', '--tags']);
|
|
28
|
-
const boolFlags = new Set(['--remember', '--cache', '--no-cache', '--help']);
|
|
31
|
+
const valueFlags = new Set(['--scope', '--name', '--server', '--key-folder', '--journey', '--connect', '--type', '--title', '--body', '--tags', '--state', '--timeout']);
|
|
32
|
+
const boolFlags = new Set(['--remember', '--cache', '--no-cache', '--help', '--no-wait', '--wait', '--json']);
|
|
29
33
|
function parse(args) {
|
|
30
34
|
const command = args[0] ?? '--help', flags = {}, positional = [];
|
|
31
35
|
for (let index = 1; index < args.length; index++) {
|
|
@@ -45,6 +49,15 @@ function parse(args) {
|
|
|
45
49
|
return { command, positional, flags };
|
|
46
50
|
}
|
|
47
51
|
function flag(flags, key) { return typeof flags[key] === 'string' ? flags[key] : undefined; }
|
|
52
|
+
function reportConnect(state, status, json) {
|
|
53
|
+
const result = { link: state.link, code: state.code, requestId: state.status === 'pending' ? state.sessionId : state.session.sessionId, expiresAt: new Date(state.expiresAt).toISOString(), status };
|
|
54
|
+
if (json)
|
|
55
|
+
console.log(JSON.stringify(result));
|
|
56
|
+
else if (status === 'pending')
|
|
57
|
+
console.log('Open this link, check the code matches, and approve access to this journey: ' + result.link + '\nSix-digit code: ' + result.code);
|
|
58
|
+
else if (status === 'approved')
|
|
59
|
+
console.log('Journey access was approved. The keys are stored in your state file.');
|
|
60
|
+
}
|
|
48
61
|
async function passphrase(folder) {
|
|
49
62
|
if (!folder)
|
|
50
63
|
return undefined;
|
|
@@ -113,11 +126,42 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
113
126
|
const server = flag(flags, '--server'), scope = flag(flags, '--scope');
|
|
114
127
|
if (scope && scope !== 'read' && scope !== 'readwrite')
|
|
115
128
|
throw new Error('Use --scope read or --scope readwrite.');
|
|
129
|
+
const statePath = flag(flags, '--state');
|
|
116
130
|
const connect = async (journeyId, mcp = false) => connectJourney(journeyId, { server, scope: scope, name: flag(flags, '--name'), remember: !!flags['--remember'], keyFolder, passphrase: secret, onApproval: (url, code) => { (mcp ? console.error : console.log)('Open this link, check the code matches, and approve access to this journey: ' + url + '\nSix-digit code: ' + code); } });
|
|
117
131
|
if (command === 'connect') {
|
|
118
|
-
const journeyId = positional[0];
|
|
119
|
-
if (
|
|
120
|
-
|
|
132
|
+
const journeyId = positional[0], json = !!flags['--json'];
|
|
133
|
+
if (flags['--no-wait']) {
|
|
134
|
+
if (!journeyId || !statePath || flags['--wait'] || flags['--remember'])
|
|
135
|
+
throw new Error('Use connect <journey-id> --state FILE --no-wait without --remember.');
|
|
136
|
+
reportConnect(await requestConnection(journeyId, { server, scope: scope, name: flag(flags, '--name'), state: statePath }), 'pending', json);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (flags['--wait']) {
|
|
140
|
+
if (journeyId || !statePath || flags['--remember'] || server || scope || flags['--name'])
|
|
141
|
+
throw new Error('Use connect --state FILE --wait [--timeout SECONDS].');
|
|
142
|
+
const timeout = flag(flags, '--timeout') ?? '600';
|
|
143
|
+
if (!/^[0-9]+$/.test(timeout) || !Number.isSafeInteger(Number(timeout)) || Number(timeout) < 1)
|
|
144
|
+
throw new Error('--timeout must be a positive number of seconds.');
|
|
145
|
+
let state;
|
|
146
|
+
try {
|
|
147
|
+
state = await loadState(statePath);
|
|
148
|
+
const { client } = await resumeConnection(statePath, { timeoutMs: Number(timeout) * 1000 });
|
|
149
|
+
client.close();
|
|
150
|
+
reportConnect(await loadState(statePath), 'approved', json);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (json && error instanceof Error && 'exitCode' in error) {
|
|
154
|
+
const status = error.exitCode === 2 ? 'pending' : error.exitCode === 3 ? 'expired' : error.exitCode === 4 ? error.message.includes('wrong approval codes') ? 'locked' : 'denied' : state?.status;
|
|
155
|
+
const reportState = state ?? (error instanceof ExpiredStateError ? error.state : undefined);
|
|
156
|
+
if (reportState && status)
|
|
157
|
+
reportConnect(reportState, status, true);
|
|
158
|
+
}
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!journeyId || flags['--timeout'] || statePath || json)
|
|
164
|
+
throw new Error('Give the journey ID to connect, or use --state FILE --no-wait / --wait.');
|
|
121
165
|
const { client } = await connect(journeyId);
|
|
122
166
|
client.close();
|
|
123
167
|
console.log(flags['--remember'] ? 'This journey is connected and the keys are remembered.' : 'Journey access was approved. No keys were saved; use wayfinding mcp --connect <journey-id> for an in-memory agent session, or --journey on each command.');
|
|
@@ -128,20 +172,27 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
128
172
|
if (held)
|
|
129
173
|
return held;
|
|
130
174
|
const oneShot = command === 'mcp' ? flag(flags, '--connect') : flag(flags, '--journey');
|
|
131
|
-
if (oneShot)
|
|
175
|
+
if (statePath && (oneShot || keyFolder))
|
|
176
|
+
throw new Error('Use --state instead of --connect, --journey or --key-folder.');
|
|
177
|
+
if (statePath) {
|
|
178
|
+
const state = await loadState(statePath);
|
|
179
|
+
if (state.status !== 'approved')
|
|
180
|
+
throw new Error('This journey is still pending approval. Run connect --state FILE --wait first.');
|
|
181
|
+
held = new JourneyClient(state.session, flags['--cache'] && !flags['--no-cache'] ? { cacheRoot: cacheFolder() } : {});
|
|
182
|
+
}
|
|
183
|
+
else if (oneShot) {
|
|
132
184
|
held = (await connect(oneShot, command === 'mcp')).client;
|
|
133
185
|
}
|
|
134
186
|
else {
|
|
135
187
|
const session = await loadRemembered({ folder: keyFolder, passphrase: secret });
|
|
136
188
|
if (!session)
|
|
137
|
-
throw new Error('No remembered journey connection. Use wayfinding connect <journey-id> --remember, or --journey <journey-id> to ask for approval for this command.
|
|
189
|
+
throw new Error('No remembered journey connection. Use wayfinding connect <journey-id> --remember, --state FILE for an approved file, or --journey <journey-id> to ask for approval for this command.');
|
|
138
190
|
held = new JourneyClient(session, flags['--no-cache'] ? {} : { cacheRoot: cacheFolder() });
|
|
139
191
|
}
|
|
140
|
-
if ((flags['--cache'] || flags['--no-cache']) && held) {
|
|
141
|
-
|
|
142
|
-
if (flags['--cache'] && oneShot)
|
|
192
|
+
if ((flags['--cache'] || flags['--no-cache']) && oneShot && held) {
|
|
193
|
+
if (flags['--cache'])
|
|
143
194
|
held = new JourneyClient(held.session, { cacheRoot: cacheFolder() });
|
|
144
|
-
if (flags['--no-cache']
|
|
195
|
+
if (flags['--no-cache'])
|
|
145
196
|
held = new JourneyClient(held.session);
|
|
146
197
|
}
|
|
147
198
|
return held;
|
|
@@ -200,4 +251,4 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
200
251
|
held?.close();
|
|
201
252
|
}
|
|
202
253
|
}
|
|
203
|
-
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
|
254
|
+
main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = error && typeof error.exitCode === 'number' ? error.exitCode : 1; });
|
package/dist/connection.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { JourneyClient } from './journey.js';
|
|
2
|
+
import type { PendingState } from './state.js';
|
|
2
3
|
import type { RememberedAgent } from './storage.js';
|
|
3
4
|
export interface ConnectOptions {
|
|
4
5
|
server?: string;
|
|
@@ -9,6 +10,8 @@ export interface ConnectOptions {
|
|
|
9
10
|
passphrase?: string;
|
|
10
11
|
fetch?: typeof fetch;
|
|
11
12
|
pollMs?: number;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
state?: string;
|
|
12
15
|
onApproval?: (url: string, code: string) => void;
|
|
13
16
|
}
|
|
14
17
|
export interface Connection {
|
|
@@ -16,4 +19,10 @@ export interface Connection {
|
|
|
16
19
|
session: RememberedAgent;
|
|
17
20
|
}
|
|
18
21
|
export declare const DEFAULT_SERVER = "https://app.wayfinding.support";
|
|
22
|
+
export declare class PendingApprovalError extends Error {
|
|
23
|
+
readonly exitCode = 2;
|
|
24
|
+
}
|
|
25
|
+
export declare function requestConnection(journeyId: string, options?: ConnectOptions): Promise<PendingState>;
|
|
26
|
+
export declare function waitForApproval(pending: PendingState, options?: ConnectOptions): Promise<Connection>;
|
|
19
27
|
export declare function connectJourney(journeyId: string, options?: ConnectOptions): Promise<Connection>;
|
|
28
|
+
export declare function resumeConnection(path: string, options?: ConnectOptions): Promise<Connection>;
|
package/dist/connection.js
CHANGED
|
@@ -1,41 +1,98 @@
|
|
|
1
1
|
import { createAgeIdentity, createSigningIdentity, validAgentName } from '@ai-wayfinding/core';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
2
3
|
import { JourneyClient } from './journey.js';
|
|
4
|
+
import { networkFetch } from './network.js';
|
|
5
|
+
import { DeniedStateError, ExpiredStateError, ensureNewStatePath, loadState, saveState } from './state.js';
|
|
3
6
|
import { saveRemembered } from './storage.js';
|
|
4
7
|
export const DEFAULT_SERVER = 'https://app.wayfinding.support';
|
|
5
8
|
const sleep = (milliseconds) => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
6
|
-
export
|
|
9
|
+
export class PendingApprovalError extends Error {
|
|
10
|
+
exitCode = 2;
|
|
11
|
+
}
|
|
12
|
+
export async function requestConnection(journeyId, options = {}) {
|
|
7
13
|
const server = (options.server ?? DEFAULT_SERVER).replace(/\/$/, '');
|
|
8
14
|
const origin = new URL(server);
|
|
9
15
|
if (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1')))
|
|
10
16
|
throw new Error('The journey server must use HTTPS (except on this computer).');
|
|
11
17
|
if (options.keyFolder && !options.remember)
|
|
12
18
|
throw new Error('--key-folder needs --remember.');
|
|
19
|
+
if (options.state && options.remember)
|
|
20
|
+
throw new Error('Use either --state or --remember, not both.');
|
|
13
21
|
if (options.name !== undefined && !validAgentName(options.name))
|
|
14
22
|
throw new Error('Agent name must be trimmed, 1–60 characters and contain no control characters.');
|
|
23
|
+
if (options.state)
|
|
24
|
+
await ensureNewStatePath(options.state);
|
|
15
25
|
const identity = await createAgeIdentity(), signing = await createSigningIdentity();
|
|
16
|
-
const response = await (options.fetch ??
|
|
26
|
+
const response = await (options.fetch ?? networkFetch)(server + '/v1/agent-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin.origin, 'X-Wayfinding': '1' }, body: JSON.stringify({ journeyId, agentPublicKey: { recipient: identity.recipient, signingKey: signing.publicKey }, requestedScope: options.scope ?? 'read', remembered: options.remember === true, keyStorage: options.state ? 'file' : 'memory', ...(options.name === undefined ? {} : { name: options.name }) }) });
|
|
17
27
|
if (!response.ok)
|
|
18
28
|
throw new Error('Could not ask to join this journey (' + response.status + ').');
|
|
19
29
|
const created = await response.json();
|
|
30
|
+
const createdAt = Date.now();
|
|
31
|
+
const pending = { status: 'pending', server, journeyId, sessionId: created.id, identity: identity.identity, recipient: identity.recipient, signingPrivateKey: signing.privateKey, signingKey: signing.publicKey, requestedScope: options.scope ?? 'read', link: created.approvalUrl, code: created.code, createdAt, expiresAt: createdAt + 600_000 };
|
|
32
|
+
if (options.state)
|
|
33
|
+
await saveState(options.state, pending, true);
|
|
20
34
|
options.onApproval?.(created.approvalUrl, created.code);
|
|
21
|
-
|
|
35
|
+
return pending;
|
|
36
|
+
}
|
|
37
|
+
export async function waitForApproval(pending, options = {}) {
|
|
38
|
+
const fetcher = options.fetch ?? networkFetch;
|
|
39
|
+
const deadline = Date.now() + (options.timeoutMs ?? 600_000);
|
|
22
40
|
for (;;) {
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
if (pending.expiresAt <= Date.now()) {
|
|
42
|
+
if (options.state)
|
|
43
|
+
await rm(options.state, { force: true });
|
|
44
|
+
throw new ExpiredStateError('Journey approval expired. Connect again.');
|
|
45
|
+
}
|
|
46
|
+
if (Date.now() >= deadline)
|
|
47
|
+
throw new PendingApprovalError('Journey approval is still pending. Run connect --state <file> --wait again.');
|
|
48
|
+
const polled = await fetcher(pending.server + '/v1/agent-sessions/' + encodeURIComponent(pending.sessionId));
|
|
25
49
|
if (!polled.ok)
|
|
26
50
|
throw new Error('Could not check journey approval (' + polled.status + ').');
|
|
27
|
-
approved = await polled.json();
|
|
28
|
-
if (approved.status === '
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
51
|
+
const approved = await polled.json();
|
|
52
|
+
if (approved.status === 'pending') {
|
|
53
|
+
await sleep(Math.min(options.pollMs ?? 3000, deadline - Date.now(), pending.expiresAt - Date.now()));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (approved.status === 'expired') {
|
|
57
|
+
if (options.state)
|
|
58
|
+
await rm(options.state, { force: true });
|
|
59
|
+
throw new ExpiredStateError('Journey approval expired. Connect again.');
|
|
60
|
+
}
|
|
61
|
+
if (approved.status !== 'approved')
|
|
62
|
+
throw new DeniedStateError(approved.status === 'locked' ? 'Too many wrong approval codes. Ask for a new journey connection.' : 'Journey approval was refused. Connect again.');
|
|
63
|
+
if (approved.journeyId !== pending.journeyId || approved.recipient !== pending.recipient || approved.signingKey !== pending.signingKey || approved.requestedScope !== pending.requestedScope)
|
|
64
|
+
throw new Error('Journey approval does not match this agent. Connect again.');
|
|
65
|
+
if (options.remember && !approved.remembered)
|
|
66
|
+
throw new Error('The person did not approve remembered journey access.');
|
|
67
|
+
if (!Number.isFinite(approved.expiresAt) || approved.expiresAt <= Date.now() || approved.expiresAt > Date.now() + (options.remember ? 90 * 86_400_000 : 8 * 3_600_000) + 60_000 || approved.scope !== 'read' && approved.scope !== 'readwrite')
|
|
68
|
+
throw new Error('Invalid journey access expiry or scope. Connect again.');
|
|
69
|
+
const expiresAt = options.state ? Math.min(approved.expiresAt, pending.createdAt + 8 * 3_600_000) : approved.expiresAt;
|
|
70
|
+
const session = { server: pending.server, journeyId: pending.journeyId, sessionId: pending.sessionId, principal: approved.principal, identity: pending.identity, recipient: pending.recipient, signingPrivateKey: pending.signingPrivateKey, signingKey: pending.signingKey, scope: approved.scope, expiresAt };
|
|
71
|
+
const client = new JourneyClient(session, { fetch: options.fetch });
|
|
72
|
+
try {
|
|
73
|
+
await client.status();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
client.close();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
if (options.remember) {
|
|
80
|
+
const store = options.keyFolder ? { folder: options.keyFolder, passphrase: options.passphrase } : {};
|
|
81
|
+
await saveRemembered(session, store);
|
|
82
|
+
}
|
|
83
|
+
if (options.state)
|
|
84
|
+
await saveState(options.state, { status: 'approved', session, createdAt: pending.createdAt, expiresAt, link: pending.link, code: pending.code });
|
|
85
|
+
return { client, session };
|
|
32
86
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
87
|
+
}
|
|
88
|
+
export async function connectJourney(journeyId, options = {}) {
|
|
89
|
+
return waitForApproval(await requestConnection(journeyId, options), options);
|
|
90
|
+
}
|
|
91
|
+
export async function resumeConnection(path, options = {}) {
|
|
92
|
+
const state = await loadState(path);
|
|
93
|
+
if (state.status === 'pending')
|
|
94
|
+
return waitForApproval(state, { ...options, state: path });
|
|
95
|
+
const client = new JourneyClient(state.session, { fetch: options.fetch });
|
|
39
96
|
try {
|
|
40
97
|
await client.status();
|
|
41
98
|
}
|
|
@@ -43,9 +100,5 @@ export async function connectJourney(journeyId, options = {}) {
|
|
|
43
100
|
client.close();
|
|
44
101
|
throw error;
|
|
45
102
|
}
|
|
46
|
-
|
|
47
|
-
const store = options.keyFolder ? { folder: options.keyFolder, passphrase: options.passphrase } : {};
|
|
48
|
-
await saveRemembered(session, store);
|
|
49
|
-
}
|
|
50
|
-
return { client, session };
|
|
103
|
+
return { client, session: state.session };
|
|
51
104
|
}
|
package/dist/journey.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { CLIENT_VERSION, meetsMinClientVersion, newId, open, parseRecord, seal, unwrapJourneyKey, verifyLog } from '@ai-wayfinding/core';
|
|
2
2
|
import { readCache, writeCache } from './cache.js';
|
|
3
3
|
import { signedHeaders } from './signing.js';
|
|
4
|
+
import { networkFetch } from './network.js';
|
|
4
5
|
const historyError = 'This journey history could not be verified. Stop and ask a member for help.';
|
|
6
|
+
const updateError = () => `This journey uses a newer format than this Wayfinding client (${CLIENT_VERSION}) understands. Nothing is wrong with the journey or your access. Update the client, then try again:\n npm install -g @ai-wayfinding/client@latest\n(If you installed it with --prefix "$HOME/.npm-global", add that same --prefix.) Your connection stays valid, so you do not need to connect or be approved again.\nIf you cannot install software here, stop and tell your operator (the person you work for) that the Wayfinding client needs updating, show them the command above, and ask them to run it or allow the update.`;
|
|
5
7
|
function decode(value) { return JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); }
|
|
6
8
|
export class JourneyClient {
|
|
7
9
|
session;
|
|
@@ -11,7 +13,7 @@ export class JourneyClient {
|
|
|
11
13
|
constructor(session, options = {}) {
|
|
12
14
|
this.session = session;
|
|
13
15
|
this.options = options;
|
|
14
|
-
this.fetcher = options.fetch ??
|
|
16
|
+
this.fetcher = options.fetch ?? networkFetch;
|
|
15
17
|
}
|
|
16
18
|
async request(path, method = 'GET', data) {
|
|
17
19
|
if (this.session.expiresAt <= Date.now())
|
|
@@ -69,13 +71,15 @@ export class JourneyClient {
|
|
|
69
71
|
entries.push(entry);
|
|
70
72
|
}
|
|
71
73
|
const checked = await verifyLog(entries);
|
|
74
|
+
if (!checked.ok && checked.error.code === 'client-too-old')
|
|
75
|
+
throw new Error(updateError());
|
|
72
76
|
if (!checked.ok || checked.state.journey !== this.session.journeyId)
|
|
73
77
|
throw new Error(historyError);
|
|
74
78
|
const mine = checked.state.members[this.session.principal]?.member;
|
|
75
79
|
if (!mine || mine.kind !== 'agent' || mine.signingKey !== this.session.signingKey || mine.recipient !== this.session.recipient || mine.expiresAt && Date.parse(mine.expiresAt) <= Date.now())
|
|
76
80
|
throw new Error('Access to this journey has ended');
|
|
77
81
|
if (!meetsMinClientVersion(CLIENT_VERSION, checked.state.minClientVersion))
|
|
78
|
-
throw new Error(
|
|
82
|
+
throw new Error(updateError());
|
|
79
83
|
if (!epochs.has(checked.state.currentEpoch))
|
|
80
84
|
throw new Error('The journey key changed. Ask a member to reconnect this agent.');
|
|
81
85
|
if (this.options.cacheRoot && (!cached || cached.seq !== checked.state.lastSeq || cached.hash !== checked.state.lastHash))
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
|
2
|
+
let configured = false;
|
|
3
|
+
export function proxyConfigured() {
|
|
4
|
+
return Boolean(process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY);
|
|
5
|
+
}
|
|
6
|
+
export class NetworkError extends Error {
|
|
7
|
+
exitCode = 5;
|
|
8
|
+
}
|
|
9
|
+
export const networkFetch = async (input, init) => {
|
|
10
|
+
if (!configured) {
|
|
11
|
+
if (proxyConfigured())
|
|
12
|
+
setGlobalDispatcher(new EnvHttpProxyAgent({
|
|
13
|
+
httpsProxy: process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY,
|
|
14
|
+
}));
|
|
15
|
+
configured = true;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return await fetch(input, init);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
let cause = error;
|
|
22
|
+
const details = [];
|
|
23
|
+
for (let i = 0; i < 4 && cause instanceof Error; i++) {
|
|
24
|
+
const code = cause.code;
|
|
25
|
+
const message = cause.message;
|
|
26
|
+
if (code || (message && message !== 'fetch failed'))
|
|
27
|
+
details.push([code, message].filter(Boolean).join(': '));
|
|
28
|
+
cause = cause.cause;
|
|
29
|
+
}
|
|
30
|
+
const hint = proxyConfigured()
|
|
31
|
+
? 'Check the configured proxy; your workspace admin may need to allow app.wayfinding.support.'
|
|
32
|
+
: 'If the host is unreachable, your workspace admin may need to allow app.wayfinding.support.';
|
|
33
|
+
throw new NetworkError('Could not reach the journey server (' + (details.join('; ') || 'network error') + '). ' + hint, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
};
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RememberedAgent } from './storage.js';
|
|
2
|
+
export interface PendingState {
|
|
3
|
+
status: 'pending';
|
|
4
|
+
server: string;
|
|
5
|
+
journeyId: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
identity: string;
|
|
8
|
+
recipient: string;
|
|
9
|
+
signingPrivateKey: string;
|
|
10
|
+
signingKey: string;
|
|
11
|
+
requestedScope: 'read' | 'readwrite';
|
|
12
|
+
link: string;
|
|
13
|
+
code: string;
|
|
14
|
+
expiresAt: number;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
}
|
|
17
|
+
export interface ApprovedState {
|
|
18
|
+
status: 'approved';
|
|
19
|
+
session: RememberedAgent;
|
|
20
|
+
link: string;
|
|
21
|
+
code: string;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
expiresAt: number;
|
|
24
|
+
}
|
|
25
|
+
export type AgentState = PendingState | ApprovedState;
|
|
26
|
+
export declare class ExpiredStateError extends Error {
|
|
27
|
+
readonly state?: AgentState | undefined;
|
|
28
|
+
readonly exitCode = 3;
|
|
29
|
+
constructor(message: string, state?: AgentState | undefined);
|
|
30
|
+
}
|
|
31
|
+
export declare class DeniedStateError extends Error {
|
|
32
|
+
readonly exitCode = 4;
|
|
33
|
+
}
|
|
34
|
+
export declare function loadState(path: string): Promise<AgentState>;
|
|
35
|
+
export declare function ensureNewStatePath(path: string): Promise<void>;
|
|
36
|
+
export declare function saveState(path: string, state: AgentState, requireNew?: boolean): Promise<void>;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export class ExpiredStateError extends Error {
|
|
5
|
+
state;
|
|
6
|
+
exitCode = 3;
|
|
7
|
+
constructor(message, state) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.state = state;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class DeniedStateError extends Error {
|
|
13
|
+
exitCode = 4;
|
|
14
|
+
}
|
|
15
|
+
const invalid = () => new Error('Invalid journey state file. Connect again.');
|
|
16
|
+
const permissions = () => new Error('Journey state file must be owned by you, be a regular file, and have permissions 0600.');
|
|
17
|
+
async function check(path) {
|
|
18
|
+
const info = await lstat(path);
|
|
19
|
+
if (!info.isFile() || (info.mode & 0o777) !== 0o600 || process.getuid && info.uid !== process.getuid())
|
|
20
|
+
throw permissions();
|
|
21
|
+
}
|
|
22
|
+
export async function loadState(path) {
|
|
23
|
+
await check(path);
|
|
24
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
const info = await handle.stat();
|
|
28
|
+
if (!info.isFile() || (info.mode & 0o777) !== 0o600 || process.getuid && info.uid !== process.getuid())
|
|
29
|
+
throw permissions();
|
|
30
|
+
value = JSON.parse(await handle.readFile({ encoding: 'utf8' }));
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await handle.close();
|
|
34
|
+
}
|
|
35
|
+
if (!value || typeof value !== 'object')
|
|
36
|
+
throw invalid();
|
|
37
|
+
const state = value;
|
|
38
|
+
if ((state.status !== 'pending' && state.status !== 'approved') || !Number.isFinite(state.createdAt) || !Number.isFinite(state.expiresAt) || state.expiresAt <= state.createdAt || state.expiresAt > state.createdAt + 8 * 3_600_000 || typeof state.link !== 'string' || typeof state.code !== 'string')
|
|
39
|
+
throw invalid();
|
|
40
|
+
if (state.status === 'pending') {
|
|
41
|
+
if (!['server', 'journeyId', 'sessionId', 'identity', 'recipient', 'signingPrivateKey', 'signingKey'].every(key => typeof state[key] === 'string' && state[key]) || state.requestedScope !== 'read' && state.requestedScope !== 'readwrite')
|
|
42
|
+
throw invalid();
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const session = state.session;
|
|
46
|
+
if (!session || !['server', 'journeyId', 'sessionId', 'principal', 'identity', 'recipient', 'signingPrivateKey', 'signingKey'].every(key => typeof session[key] === 'string' && session[key]) || session.scope !== 'read' && session.scope !== 'readwrite' || session.expiresAt !== state.expiresAt)
|
|
47
|
+
throw invalid();
|
|
48
|
+
}
|
|
49
|
+
if (state.expiresAt <= Date.now()) {
|
|
50
|
+
await rm(path);
|
|
51
|
+
throw new ExpiredStateError('Journey state file expired and was deleted. Connect again.', state);
|
|
52
|
+
}
|
|
53
|
+
return state;
|
|
54
|
+
}
|
|
55
|
+
export async function ensureNewStatePath(path) {
|
|
56
|
+
try {
|
|
57
|
+
await check(path);
|
|
58
|
+
throw new Error('Journey state file already exists. Choose a new --state file.');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code !== 'ENOENT')
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function saveState(path, state, requireNew = false) {
|
|
66
|
+
await mkdir(dirname(path), { recursive: true });
|
|
67
|
+
let exists = false;
|
|
68
|
+
try {
|
|
69
|
+
await check(path);
|
|
70
|
+
exists = true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== 'ENOENT')
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
if (exists && requireNew)
|
|
77
|
+
throw new Error('Journey state file already exists. Choose a new --state file.');
|
|
78
|
+
if (!exists) {
|
|
79
|
+
const handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
80
|
+
try {
|
|
81
|
+
await handle.writeFile(JSON.stringify(state));
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Replacement is atomic: readers cannot observe a partially written session.
|
|
89
|
+
const temp = join(dirname(path), '.wayfinding-' + crypto.randomUUID());
|
|
90
|
+
try {
|
|
91
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
92
|
+
try {
|
|
93
|
+
await handle.writeFile(JSON.stringify(state));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await handle.close();
|
|
97
|
+
}
|
|
98
|
+
await check(path);
|
|
99
|
+
await rename(temp, path);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await rm(temp, { force: true });
|
|
103
|
+
}
|
|
104
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-wayfinding/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
"test:mcp": "vitest run test/integration.test.ts -t MCP"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@ai-wayfinding/core": "0.1.
|
|
29
|
+
"@ai-wayfinding/core": "0.1.2",
|
|
30
|
+
"undici": "^7.29.0",
|
|
30
31
|
"@modelcontextprotocol/sdk": "^1.30.1"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|