@dfm-fi/agent 0.2.1896 → 0.2.1916
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/dist/app-wallet-owner.d.ts +4 -0
- package/dist/app-wallet-owner.js +4 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +1 -1
- package/dist/mcp-server.js +114 -5
- package/dist/onboarding-api.d.ts +42 -0
- package/dist/onboarding-api.js +102 -0
- package/dist/onboarding.d.ts +93 -0
- package/dist/onboarding.js +275 -0
- package/package.json +11 -3
- package/skills.md +6 -2
|
@@ -20,6 +20,10 @@ export interface OwnerKey {
|
|
|
20
20
|
created: boolean;
|
|
21
21
|
sign(payload: string): string;
|
|
22
22
|
}
|
|
23
|
+
export declare const lstatOrNull: (p: string) => import("node:fs").Stats | null;
|
|
24
|
+
export declare const myUid: () => number | undefined;
|
|
25
|
+
/** The owner-key directory rules, shared by every secret this MCP writes (identity keypair too). */
|
|
26
|
+
export declare function assertSafeDir(dir: string): void;
|
|
23
27
|
/** Load the owner key at `path`, or create it (0600) when absent. */
|
|
24
28
|
export declare function loadOrCreateOwnerKey(path?: string): OwnerKey;
|
|
25
29
|
/** RFC 8785 (JCS) for the JSON shapes Privy signs: sorted keys, ES string/number serialization. */
|
package/dist/app-wallet-owner.js
CHANGED
|
@@ -45,7 +45,7 @@ export function signerPins() {
|
|
|
45
45
|
export function ownerKeyPath() {
|
|
46
46
|
return process.env.DFM_APP_WALLET_OWNER_KEY_PATH?.trim() || DEFAULT_OWNER_KEY_PATH;
|
|
47
47
|
}
|
|
48
|
-
const lstatOrNull = (p) => {
|
|
48
|
+
export const lstatOrNull = (p) => {
|
|
49
49
|
try {
|
|
50
50
|
return lstatSync(p);
|
|
51
51
|
}
|
|
@@ -55,8 +55,9 @@ const lstatOrNull = (p) => {
|
|
|
55
55
|
throw e;
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
|
-
const myUid = () => (typeof process.getuid === 'function' ? process.getuid() : undefined);
|
|
59
|
-
|
|
58
|
+
export const myUid = () => (typeof process.getuid === 'function' ? process.getuid() : undefined);
|
|
59
|
+
/** The owner-key directory rules, shared by every secret this MCP writes (identity keypair too). */
|
|
60
|
+
export function assertSafeDir(dir) {
|
|
60
61
|
let st = lstatOrNull(dir);
|
|
61
62
|
if (!st) {
|
|
62
63
|
if (resolve(dir) !== resolve(DEFAULT_OWNER_KEY_DIR))
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-config convenience: if `DFM_AGENT_KEYPAIR_PATH` is unset, fall back to the
|
|
3
|
+
* conventional `~/.config/dfm/agent-wallet.json` IF it exists. So a plugin user
|
|
4
|
+
* can just drop their throwaway test keypair there and skip the env var entirely.
|
|
5
|
+
* Returns undefined when neither is present (write tools then error clearly).
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_KEYPAIR_PATH: string;
|
|
1
8
|
export interface AgentConfig {
|
|
2
9
|
/** DFM v2 backend API base, INCLUDING the `/api/v2` prefix. */
|
|
3
10
|
apiUrl: string;
|
package/dist/config.js
CHANGED
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
* can just drop their throwaway test keypair there and skip the env var entirely.
|
|
21
21
|
* Returns undefined when neither is present (write tools then error clearly).
|
|
22
22
|
*/
|
|
23
|
-
const DEFAULT_KEYPAIR_PATH = join(homedir(), '.config', 'dfm', 'agent-wallet.json');
|
|
23
|
+
export const DEFAULT_KEYPAIR_PATH = join(homedir(), '.config', 'dfm', 'agent-wallet.json');
|
|
24
24
|
function resolveKeypairPath() {
|
|
25
25
|
const explicit = process.env.DFM_AGENT_KEYPAIR_PATH;
|
|
26
26
|
if (explicit && explicit.trim().length > 0)
|
package/dist/mcp-server.js
CHANGED
|
@@ -67,11 +67,16 @@ import { isKeylessMode, executeAppWalletWrite, KEYLESS_SETUP_HINT, } from './key
|
|
|
67
67
|
import { runAcceptAdminTransfer, runUpdateVaultPolicy, reconcileKeylessUpdateVaultPolicy, } from './manager-flow.js';
|
|
68
68
|
import { submitSignedTx } from './submit.js';
|
|
69
69
|
import { DEFAULT_OWNER_KEY_PATH, ownerKeyPath, policyUpdateNotice, runCreateAppWallet } from './app-wallet-owner.js';
|
|
70
|
-
import { signBase64Transaction } from './signing.js';
|
|
70
|
+
import { getTestWalletPubkey, signBase64Transaction } from './signing.js';
|
|
71
|
+
import { ONBOARDING_ORDER, avatarPath, ensureIdentityWallet, loadAvatar, mintAndStoreKey, runOnboard } from './onboarding.js';
|
|
72
|
+
import { makeIdentityGuard, makeKeyOwnerLookup, makeSessionFetch, onboardingApi } from './onboarding-api.js';
|
|
71
73
|
import { buildWalletIdentity, resolveZapStatusWallet, withActingWalletAlias, withPortfolioWalletAlias, } from './wallet-identity.js';
|
|
72
74
|
const config = loadConfig();
|
|
73
75
|
const session = new SiwsSession(config);
|
|
74
76
|
const api = new DfmApiClient(config, session);
|
|
77
|
+
// S16/S17/S19: with DFM_API_KEY set, the key owner (asked over the KEY lane) must BE the SIWS wallet.
|
|
78
|
+
const assertSameIdentity = makeIdentityGuard(makeKeyOwnerLookup(config), () => session.walletAddress);
|
|
79
|
+
const onboardApi = onboardingApi(makeSessionFetch(config, session, fetch, assertSameIdentity));
|
|
75
80
|
// Read the real published version from package.json so serverInfo matches the
|
|
76
81
|
// npm `0.2.<commitcount>` instead of a hardcoded string that silently drifts.
|
|
77
82
|
const PKG_VERSION = (() => {
|
|
@@ -378,8 +383,8 @@ server.registerTool('app_wallet', {
|
|
|
378
383
|
if (!grant?.operatingWalletAddress) {
|
|
379
384
|
return text({
|
|
380
385
|
hasAppWallet: false,
|
|
381
|
-
nextStep: `No app wallet yet.
|
|
382
|
-
|
|
386
|
+
nextStep: `No app wallet yet. Headless agent: ${ONBOARDING_ORDER} (or just run onboard). ` +
|
|
387
|
+
`In a browser: sign in at ${SETUP_URL}.`,
|
|
383
388
|
});
|
|
384
389
|
}
|
|
385
390
|
// Best-effort: an older API without this route must still report the
|
|
@@ -523,12 +528,116 @@ server.registerTool('create_app_wallet', {
|
|
|
523
528
|
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
524
529
|
}, async () => {
|
|
525
530
|
try {
|
|
531
|
+
await assertSameIdentity();
|
|
526
532
|
return text(await runCreateAppWallet(api, ownerKeyPath()));
|
|
527
533
|
}
|
|
528
534
|
catch (e) {
|
|
529
535
|
return errText(`create_app_wallet failed: ${e.message}`);
|
|
530
536
|
}
|
|
531
537
|
});
|
|
538
|
+
server.registerTool('create_identity_wallet', {
|
|
539
|
+
title: 'Identity wallet (headless sign-in key)',
|
|
540
|
+
description: "The Solana keypair this agent signs in to DFM with (SIWS). If DFM_AGENT_KEYPAIR_PATH / DFM_AGENT_KEYPAIR_JSON " +
|
|
541
|
+
'is set or ~/.config/dfm/agent-wallet.json exists it is REUSED (never a second one); otherwise one is created ' +
|
|
542
|
+
'there once (0600, never overwritten) and used immediately, no restart. Returns only the public key. Step 1 of: ' +
|
|
543
|
+
ONBOARDING_ORDER + '. Moves no funds.',
|
|
544
|
+
inputSchema: {},
|
|
545
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: false },
|
|
546
|
+
}, async () => {
|
|
547
|
+
try {
|
|
548
|
+
return text(ensureIdentityWallet(config, getTestWalletPubkey));
|
|
549
|
+
}
|
|
550
|
+
catch (e) {
|
|
551
|
+
return errText(`create_identity_wallet failed: ${e.message}`);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
server.registerTool('set_profile', {
|
|
555
|
+
title: 'Set my DFM profile',
|
|
556
|
+
description: 'Set this agent\'s public DFM profile: displayName (required), optional @username and bio. The avatar is the ' +
|
|
557
|
+
'image the OPERATOR put at DFM_AGENT_AVATAR_PATH or ~/.config/dfm/avatar.{png,jpg,jpeg,webp,gif} (≤ 2 MB, ' +
|
|
558
|
+
'checked locally) — the model cannot point it at any other file. A chosen name AND an avatar are required ' +
|
|
559
|
+
'before create_app_wallet. Needs the SIWS sign-in (identity wallet). Moves no funds.',
|
|
560
|
+
inputSchema: {
|
|
561
|
+
displayName: z.string().min(1).max(32),
|
|
562
|
+
username: z.string().regex(/^@?[a-zA-Z0-9_]{3,20}$/).optional().describe('@handle, 3-20 chars a-z 0-9 _'),
|
|
563
|
+
bio: z.string().max(160).optional(),
|
|
564
|
+
uploadAvatar: z.boolean().optional().describe('default true: upload the operator-provided avatar image if present'),
|
|
565
|
+
},
|
|
566
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
567
|
+
}, async ({ displayName, username, bio, uploadAvatar }) => {
|
|
568
|
+
try {
|
|
569
|
+
const profile = await onboardApi.updateProfile({
|
|
570
|
+
displayName,
|
|
571
|
+
...(username ? { username: username.replace(/^@/, '') } : {}),
|
|
572
|
+
...(bio !== undefined ? { bio } : {}),
|
|
573
|
+
});
|
|
574
|
+
let avatar = { uploaded: false };
|
|
575
|
+
const path = uploadAvatar === false ? null : avatarPath();
|
|
576
|
+
if (path) {
|
|
577
|
+
const img = loadAvatar(path);
|
|
578
|
+
const res = await onboardApi.uploadAvatar(img.bytes, img.filename, img.mime);
|
|
579
|
+
avatar = { uploaded: true, effectiveAvatarUri: res.effectiveAvatarUri ?? null };
|
|
580
|
+
}
|
|
581
|
+
else if (uploadAvatar !== false) {
|
|
582
|
+
avatar = { uploaded: false, hint: 'No avatar file. Put an image at ~/.config/dfm/avatar.png (or set DFM_AGENT_AVATAR_PATH) and run set_profile again.' };
|
|
583
|
+
}
|
|
584
|
+
return text({ profile: { displayName: profile.displayName, username: profile.username, bio: profile.bio }, avatar });
|
|
585
|
+
}
|
|
586
|
+
catch (e) {
|
|
587
|
+
return errText(`set_profile failed: ${e.message}`);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
server.registerTool('create_api_key', {
|
|
591
|
+
title: 'Mint a dfm_ API key',
|
|
592
|
+
description: 'Mint a dfm_ API key for this agent\'s owner (SIWS session only). READ-ONLY: write keys and write caps are ' +
|
|
593
|
+
'refused unless the operator set DFM_ALLOW_WRITE_KEY_MINT=1. Expires in 30 days by default (max 90). The key ' +
|
|
594
|
+
'is written to ~/.config/dfm/api-keys/<name>-<prefix>.key (0600) and NEVER returned to the chat; only its ' +
|
|
595
|
+
'prefix and path are. Optional last step of: ' + ONBOARDING_ORDER + '.',
|
|
596
|
+
inputSchema: {
|
|
597
|
+
name: z.string().min(1).max(80),
|
|
598
|
+
readOnly: z.boolean().optional().describe('default true'),
|
|
599
|
+
perOpNotionalUsdc: z.number().int().min(0).optional(),
|
|
600
|
+
dailyNotionalUsdc: z.number().int().min(0).optional(),
|
|
601
|
+
allowedOps: z.array(z.string().max(64)).max(32).optional(),
|
|
602
|
+
destinationWhitelist: z.array(z.string().max(64)).max(32).optional(),
|
|
603
|
+
allowedPrograms: z.array(z.string().max(64)).max(32).optional(),
|
|
604
|
+
allowArbitraryPrograms: z.boolean().optional(),
|
|
605
|
+
expiresAt: z.string().datetime().optional().describe('ISO-8601, in the future'),
|
|
606
|
+
},
|
|
607
|
+
annotations: { readOnlyHint: false, idempotentHint: false, openWorldHint: true },
|
|
608
|
+
}, async (input) => {
|
|
609
|
+
try {
|
|
610
|
+
return text(await mintAndStoreKey(input, onboardApi));
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
return errText(`create_api_key failed: ${e.message}`);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
server.registerTool('onboard', {
|
|
617
|
+
title: 'Onboard this agent (headless)',
|
|
618
|
+
description: 'Run the headless onboarding in order — ' + ONBOARDING_ORDER + ' — and stop at the first step that needs the ' +
|
|
619
|
+
'operator, saying which. Creates the identity wallet and the app wallet if missing; never mints an API key. ' +
|
|
620
|
+
'Moves no funds.',
|
|
621
|
+
inputSchema: {},
|
|
622
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
623
|
+
}, async () => {
|
|
624
|
+
try {
|
|
625
|
+
return text(await runOnboard({
|
|
626
|
+
identity: () => ensureIdentityWallet(config, getTestWalletPubkey),
|
|
627
|
+
...(process.env.DFM_API_KEY?.trim()
|
|
628
|
+
? { assertSameIdentity }
|
|
629
|
+
: {}),
|
|
630
|
+
getProfile: () => onboardApi.getProfile(),
|
|
631
|
+
createAppWallet: async () => {
|
|
632
|
+
await assertSameIdentity();
|
|
633
|
+
return runCreateAppWallet(api, ownerKeyPath());
|
|
634
|
+
},
|
|
635
|
+
}));
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
return errText(`onboard failed: ${e.message}`);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
532
641
|
server.registerTool('xp_status', {
|
|
533
642
|
title: 'XP',
|
|
534
643
|
description: "The user's XP (total and by kind: deposit, launch, profile, referral) and the top of the XP leaderboard.",
|
|
@@ -568,8 +677,8 @@ server.registerTool('whoami', {
|
|
|
568
677
|
...(identity.actingWallet
|
|
569
678
|
? {}
|
|
570
679
|
: {
|
|
571
|
-
setupHint:
|
|
572
|
-
'
|
|
680
|
+
setupHint: `No active app-wallet signer. Onboard in this order: ${ONBOARDING_ORDER} (or run onboard). ` +
|
|
681
|
+
'An external keypair cannot sign fund operations.',
|
|
573
682
|
}),
|
|
574
683
|
...policyUpdateNotice(op),
|
|
575
684
|
apiUrl: config.apiUrl,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentConfig } from './config.js';
|
|
2
|
+
import type { SiwsSession } from './session.js';
|
|
3
|
+
export type SessionFetch = (path: string, init: RequestInit & {
|
|
4
|
+
json?: unknown;
|
|
5
|
+
}) => Promise<Record<string, unknown>>;
|
|
6
|
+
/**
|
|
7
|
+
* Review S16/S17: when DFM_API_KEY is set, the key's owner and the SIWS
|
|
8
|
+
* keypair must be the SAME wallet, or the session lane would act as a
|
|
9
|
+
* different identity than the rest of the MCP. The owner comes from
|
|
10
|
+
* `GET /agent/operating/embedded/agent-wallet/owner` over the KEY lane (answers for any key, read-only included, and
|
|
11
|
+
* always carries `wallet`). FAIL CLOSED: an error or an unknown owner refuses.
|
|
12
|
+
* A passing check is cached for the process; a failing one is retried.
|
|
13
|
+
*/
|
|
14
|
+
export declare function makeIdentityGuard(keyOwner: () => Promise<{
|
|
15
|
+
wallet?: unknown;
|
|
16
|
+
} | null | undefined>, siwsWallet: () => string, hasApiKey?: () => boolean): () => Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* The identity guard's owner lookup, over the KEY lane on purpose (review
|
|
19
|
+
* S19): `X-DFM-Key`, never the SIWS bearer, against the route that answers
|
|
20
|
+
* for any key (read-only included) even when the owner has no app wallet.
|
|
21
|
+
* Errors throw — the guard turns them into a refusal (fail closed).
|
|
22
|
+
*/
|
|
23
|
+
export declare function makeKeyOwnerLookup(config: Pick<AgentConfig, 'apiUrl'>, apiKey?: () => string | undefined, doFetch?: typeof fetch): () => Promise<{
|
|
24
|
+
wallet?: unknown;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function makeSessionFetch(config: Pick<AgentConfig, 'apiUrl'>, session: Pick<SiwsSession, 'getAccessToken'>, doFetch?: typeof fetch, beforeEach?: () => Promise<void>): SessionFetch;
|
|
27
|
+
export declare function onboardingApi(call: SessionFetch): {
|
|
28
|
+
getProfile: () => Promise<{
|
|
29
|
+
wallet?: string;
|
|
30
|
+
displayName?: string;
|
|
31
|
+
effectiveAvatarUri?: string | null;
|
|
32
|
+
}>;
|
|
33
|
+
updateProfile: (dto: {
|
|
34
|
+
displayName: string;
|
|
35
|
+
username?: string;
|
|
36
|
+
bio?: string;
|
|
37
|
+
}) => Promise<Record<string, unknown>>;
|
|
38
|
+
uploadAvatar: (bytes: Buffer, filename: string, mime: string) => Promise<Record<string, unknown>>;
|
|
39
|
+
createApiKey: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
40
|
+
revokeApiKey: (id: string) => Promise<Record<string, unknown>>;
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=onboarding-api.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The onboarding calls (profile, avatar, API key), always over the SIWS
|
|
3
|
+
* session — never DFM_API_KEY: a key cannot mint a key (`/agent/keys` is
|
|
4
|
+
* session-only) and a read-only key cannot write a profile. Kept beside
|
|
5
|
+
* `DfmApiClient` rather than inside it; same contract as its SIWS lane:
|
|
6
|
+
* Bearer token, one transparent re-auth on 401, an `X-Idempotency-Key` on
|
|
7
|
+
* every write, the API's error text surfaced verbatim.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
/**
|
|
11
|
+
* Review S16/S17: when DFM_API_KEY is set, the key's owner and the SIWS
|
|
12
|
+
* keypair must be the SAME wallet, or the session lane would act as a
|
|
13
|
+
* different identity than the rest of the MCP. The owner comes from
|
|
14
|
+
* `GET /agent/operating/embedded/agent-wallet/owner` over the KEY lane (answers for any key, read-only included, and
|
|
15
|
+
* always carries `wallet`). FAIL CLOSED: an error or an unknown owner refuses.
|
|
16
|
+
* A passing check is cached for the process; a failing one is retried.
|
|
17
|
+
*/
|
|
18
|
+
export function makeIdentityGuard(keyOwner, siwsWallet, hasApiKey = () => Boolean(process.env.DFM_API_KEY?.trim())) {
|
|
19
|
+
let ok = null;
|
|
20
|
+
return () => {
|
|
21
|
+
if (!hasApiKey())
|
|
22
|
+
return Promise.resolve();
|
|
23
|
+
ok ??= (async () => {
|
|
24
|
+
let owner;
|
|
25
|
+
try {
|
|
26
|
+
owner = (await keyOwner())?.wallet;
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
throw new Error(`cannot verify whose DFM_API_KEY this is (${e.message}); refusing to act`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof owner !== 'string' || !owner)
|
|
32
|
+
throw new Error('cannot verify whose DFM_API_KEY this is; refusing to act');
|
|
33
|
+
const me = siwsWallet();
|
|
34
|
+
if (owner !== me) {
|
|
35
|
+
throw new Error(`DFM_API_KEY belongs to ${owner}, not this keypair (${me}); use one identity (unset one of them)`);
|
|
36
|
+
}
|
|
37
|
+
})().catch((e) => {
|
|
38
|
+
ok = null;
|
|
39
|
+
throw e;
|
|
40
|
+
});
|
|
41
|
+
return ok;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The identity guard's owner lookup, over the KEY lane on purpose (review
|
|
46
|
+
* S19): `X-DFM-Key`, never the SIWS bearer, against the route that answers
|
|
47
|
+
* for any key (read-only included) even when the owner has no app wallet.
|
|
48
|
+
* Errors throw — the guard turns them into a refusal (fail closed).
|
|
49
|
+
*/
|
|
50
|
+
export function makeKeyOwnerLookup(config, apiKey = () => process.env.DFM_API_KEY?.trim(), doFetch = fetch) {
|
|
51
|
+
return async () => {
|
|
52
|
+
const key = apiKey();
|
|
53
|
+
if (!key)
|
|
54
|
+
throw new Error('no DFM_API_KEY to look up');
|
|
55
|
+
const res = await doFetch(`${config.apiUrl}/agent/operating/embedded/agent-wallet/owner`, {
|
|
56
|
+
method: 'GET',
|
|
57
|
+
headers: { 'X-DFM-Key': key },
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok)
|
|
60
|
+
throw new Error(`owner lookup answered ${res.status}`);
|
|
61
|
+
return (await res.json());
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function makeSessionFetch(config, session, doFetch = fetch, beforeEach = async () => undefined) {
|
|
65
|
+
return async (path, { json, ...init }) => {
|
|
66
|
+
await beforeEach();
|
|
67
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
68
|
+
const idem = method === 'GET' ? {} : { 'X-Idempotency-Key': randomUUID() };
|
|
69
|
+
const send = (token) => doFetch(`${config.apiUrl}${path}`, {
|
|
70
|
+
...init,
|
|
71
|
+
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
|
|
72
|
+
headers: {
|
|
73
|
+
...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
74
|
+
...idem,
|
|
75
|
+
Authorization: `Bearer ${token}`,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
let res = await send(await session.getAccessToken());
|
|
79
|
+
if (res.status === 401)
|
|
80
|
+
res = await send(await session.getAccessToken(true));
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const body = await res.text().catch(() => '');
|
|
83
|
+
throw new Error(`DFM API ${res.status} on ${path}: ${body.slice(0, 400)}`);
|
|
84
|
+
}
|
|
85
|
+
const text = await res.text();
|
|
86
|
+
return (text ? JSON.parse(text) : {});
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function onboardingApi(call) {
|
|
90
|
+
return {
|
|
91
|
+
getProfile: () => call('/profile', { method: 'GET' }),
|
|
92
|
+
updateProfile: (dto) => call('/profile', { method: 'PUT', json: dto }),
|
|
93
|
+
uploadAvatar: (bytes, filename, mime) => {
|
|
94
|
+
const form = new FormData();
|
|
95
|
+
form.append('file', new Blob([new Uint8Array(bytes)], { type: mime }), filename);
|
|
96
|
+
return call('/profile/avatar', { method: 'POST', body: form });
|
|
97
|
+
},
|
|
98
|
+
createApiKey: (body) => call('/agent/keys', { method: 'POST', json: body }),
|
|
99
|
+
revokeApiKey: (id) => call(`/agent/keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=onboarding-api.js.map
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type AgentConfig } from './config.js';
|
|
2
|
+
export declare const ONBOARDING_ORDER = "create_identity_wallet \u2192 SIWS login (automatic) \u2192 set_profile {displayName, avatar} \u2192 create_app_wallet \u2192 create_api_key (optional)";
|
|
3
|
+
export interface IdentityResult {
|
|
4
|
+
status: 'reused' | 'created';
|
|
5
|
+
publicKey: string;
|
|
6
|
+
source: 'DFM_AGENT_KEYPAIR_PATH' | 'DFM_AGENT_KEYPAIR_JSON' | 'default-path';
|
|
7
|
+
note: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Reuse the configured identity keypair, or create ONE at the default path.
|
|
11
|
+
* Never creates a second: any explicit env or an existing default file wins.
|
|
12
|
+
* `getPubkey` loads it through the same code SIWS uses (it refuses the
|
|
13
|
+
* protocol wallet). On create, `config.keypairPath` is pointed at the new file
|
|
14
|
+
* so the running MCP signs in with it without a restart.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensureIdentityWallet(config: AgentConfig, getPubkey: (c: AgentConfig) => string, defaultPath?: string): IdentityResult;
|
|
17
|
+
/** A secret file this MCP may READ: regular file, not a symlink, ours, 0600-ish. */
|
|
18
|
+
export declare function assertSafeSecretFile(path: string): void;
|
|
19
|
+
export declare const MAX_AVATAR_BYTES: number;
|
|
20
|
+
/** Where the operator puts the agent's avatar. Null when none is configured / present. */
|
|
21
|
+
export declare function avatarPath(dir?: string): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Read the avatar ONLY if it is a real image: allowed extension, not a symlink,
|
|
24
|
+
* a regular file ≤ 2 MB whose first bytes match that image type. Nothing else
|
|
25
|
+
* is ever sent (the server re-checks magic bytes too).
|
|
26
|
+
*/
|
|
27
|
+
export declare function loadAvatar(path: string): {
|
|
28
|
+
bytes: Buffer;
|
|
29
|
+
filename: string;
|
|
30
|
+
mime: string;
|
|
31
|
+
};
|
|
32
|
+
/** The API's gate rule (agent-onboarding.ts), mirrored: a chosen name AND an avatar. */
|
|
33
|
+
export declare function profileGap(wallet: string | undefined, profile: {
|
|
34
|
+
displayName?: string;
|
|
35
|
+
effectiveAvatarUri?: string | null;
|
|
36
|
+
} | null | undefined): string[];
|
|
37
|
+
export interface CreateKeyInput {
|
|
38
|
+
name: string;
|
|
39
|
+
readOnly?: boolean;
|
|
40
|
+
perOpNotionalUsdc?: number;
|
|
41
|
+
dailyNotionalUsdc?: number;
|
|
42
|
+
allowedOps?: string[];
|
|
43
|
+
destinationWhitelist?: string[];
|
|
44
|
+
allowedPrograms?: string[];
|
|
45
|
+
allowArbitraryPrograms?: boolean;
|
|
46
|
+
expiresAt?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const DEFAULT_KEY_TTL_MS: number;
|
|
49
|
+
export declare const MAX_KEY_TTL_MS: number;
|
|
50
|
+
/**
|
|
51
|
+
* The exact `POST /agent/keys` body (CreateKeyDto). Review S12: a model can be
|
|
52
|
+
* prompt-injected, so the MCP mints READ-ONLY keys only; a write key (or any
|
|
53
|
+
* write cap) needs the OPERATOR's `DFM_ALLOW_WRITE_KEY_MINT=1`. Every key
|
|
54
|
+
* expires: default now + 30 days, never more than 90.
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildCreateKeyBody(input: CreateKeyInput, now?: number): Record<string, unknown>;
|
|
57
|
+
export declare const DEFAULT_API_KEYS_DIR: string;
|
|
58
|
+
/** Directory + name checks, run BEFORE minting (review S18): a key is never minted into nowhere. */
|
|
59
|
+
export declare function prepareKeyStore(name: string, dir?: string): {
|
|
60
|
+
dir: string;
|
|
61
|
+
safeName: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Write a freshly minted key to `<dir>/<name>-<prefix>.key` (dir 0700, file
|
|
65
|
+
* 0600, `wx`) and return everything EXCEPT the plaintext: the key never
|
|
66
|
+
* enters the chat, so a prompt cannot make the agent paste it anywhere.
|
|
67
|
+
*/
|
|
68
|
+
export declare function storeApiKey(name: string, issued: Record<string, unknown>, dir?: string): Record<string, unknown>;
|
|
69
|
+
/**
|
|
70
|
+
* The whole create_api_key: check the store, mint, store; if storing fails
|
|
71
|
+
* after the mint, REVOKE the key so no live credential is left unrecorded.
|
|
72
|
+
*/
|
|
73
|
+
export declare function mintAndStoreKey(input: CreateKeyInput, api: {
|
|
74
|
+
createApiKey(b: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
75
|
+
revokeApiKey(id: string): Promise<unknown>;
|
|
76
|
+
}, dir?: string): Promise<Record<string, unknown>>;
|
|
77
|
+
export interface OnboardDeps {
|
|
78
|
+
identity(): IdentityResult;
|
|
79
|
+
/** Only when DFM_API_KEY is set: throws unless the key's owner IS the SIWS wallet (fail closed). */
|
|
80
|
+
assertSameIdentity?(): Promise<void>;
|
|
81
|
+
getProfile(): Promise<{
|
|
82
|
+
wallet?: string;
|
|
83
|
+
displayName?: string;
|
|
84
|
+
effectiveAvatarUri?: string | null;
|
|
85
|
+
} | null>;
|
|
86
|
+
createAppWallet(): Promise<Record<string, unknown>>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Run the steps in order and stop at the first one that needs the operator.
|
|
90
|
+
* Never mints an API key (that grants authority — the agent asks for it explicitly).
|
|
91
|
+
*/
|
|
92
|
+
export declare function runOnboard(deps: OnboardDeps): Promise<Record<string, unknown>>;
|
|
93
|
+
//# sourceMappingURL=onboarding.d.ts.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HEADLESS-AGENT ONBOARDING over MCP (Top, #101, 2026-10-19): identity wallet →
|
|
3
|
+
* SIWS login → profile (name + avatar) → app wallet → API key, no browser.
|
|
4
|
+
*
|
|
5
|
+
* Everything that touches the disk follows the owner-key rules
|
|
6
|
+
* (`app-wallet-owner.ts`): no symlinks, no foreign uid, no loose perms, the
|
|
7
|
+
* only directory ever created is `~/.config/dfm`, secrets written once with
|
|
8
|
+
* `wx`/0600. The MODEL never chooses a path: the identity keypair lives at
|
|
9
|
+
* `DFM_AGENT_KEYPAIR_PATH` or the default, the avatar at `DFM_AGENT_AVATAR_PATH`
|
|
10
|
+
* or `~/.config/dfm/avatar.<ext>` — so a prompt cannot make the agent upload or
|
|
11
|
+
* overwrite an arbitrary file.
|
|
12
|
+
*/
|
|
13
|
+
import { Keypair } from '@solana/web3.js';
|
|
14
|
+
import { closeSync, constants as fsc, fstatSync, mkdirSync, openSync, readSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, extname, join } from 'node:path';
|
|
16
|
+
import { DEFAULT_OWNER_KEY_DIR, assertSafeDir, lstatOrNull, myUid } from './app-wallet-owner.js';
|
|
17
|
+
import { DEFAULT_KEYPAIR_PATH } from './config.js';
|
|
18
|
+
export const ONBOARDING_ORDER = 'create_identity_wallet → SIWS login (automatic) → set_profile {displayName, avatar} → create_app_wallet → create_api_key (optional)';
|
|
19
|
+
/**
|
|
20
|
+
* Reuse the configured identity keypair, or create ONE at the default path.
|
|
21
|
+
* Never creates a second: any explicit env or an existing default file wins.
|
|
22
|
+
* `getPubkey` loads it through the same code SIWS uses (it refuses the
|
|
23
|
+
* protocol wallet). On create, `config.keypairPath` is pointed at the new file
|
|
24
|
+
* so the running MCP signs in with it without a restart.
|
|
25
|
+
*/
|
|
26
|
+
export function ensureIdentityWallet(config, getPubkey, defaultPath = DEFAULT_KEYPAIR_PATH) {
|
|
27
|
+
const explicit = process.env.DFM_AGENT_KEYPAIR_PATH?.trim();
|
|
28
|
+
if (explicit) {
|
|
29
|
+
return { status: 'reused', publicKey: getPubkey(config), source: 'DFM_AGENT_KEYPAIR_PATH', note: 'Reused the identity keypair at DFM_AGENT_KEYPAIR_PATH; nothing was created.' };
|
|
30
|
+
}
|
|
31
|
+
if (config.keypairJsonEnvPresent && !config.keypairPath && !lstatOrNull(defaultPath)) {
|
|
32
|
+
return { status: 'reused', publicKey: getPubkey(config), source: 'DFM_AGENT_KEYPAIR_JSON', note: 'Reused the identity keypair from DFM_AGENT_KEYPAIR_JSON; nothing was created.' };
|
|
33
|
+
}
|
|
34
|
+
if (lstatOrNull(defaultPath)) {
|
|
35
|
+
assertSafeSecretFile(defaultPath);
|
|
36
|
+
config.keypairPath = defaultPath;
|
|
37
|
+
const both = config.keypairJsonEnvPresent
|
|
38
|
+
? ' DFM_AGENT_KEYPAIR_JSON is also set: both present; the file wins (same as the signer).'
|
|
39
|
+
: '';
|
|
40
|
+
return { status: 'reused', publicKey: getPubkey(config), source: 'default-path', note: `Reused the identity keypair at ${defaultPath}; nothing was created.${both}` };
|
|
41
|
+
}
|
|
42
|
+
// Keyless mode already has an identity: the API key's owner. A second,
|
|
43
|
+
// unrelated SIWS identity would split the agent in two (review S13).
|
|
44
|
+
if (process.env.DFM_API_KEY?.trim()) {
|
|
45
|
+
throw new Error('this MCP already acts for its owner via DFM_API_KEY; identity creation is for keypair-less agents with no key');
|
|
46
|
+
}
|
|
47
|
+
assertSafeDir(dirname(defaultPath));
|
|
48
|
+
const kp = Keypair.generate();
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(defaultPath, JSON.stringify(Array.from(kp.secretKey)), { mode: 0o600, flag: 'wx' });
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
kp.secretKey.fill(0);
|
|
54
|
+
}
|
|
55
|
+
config.keypairPath = defaultPath;
|
|
56
|
+
return {
|
|
57
|
+
status: 'created',
|
|
58
|
+
publicKey: getPubkey(config),
|
|
59
|
+
source: 'default-path',
|
|
60
|
+
note: `Created a new identity keypair at ${defaultPath} (0600) — back it up offline; it is how this agent signs in. ` +
|
|
61
|
+
'The running MCP already uses it for SIWS (no restart needed). Fund it with a little SOL only if it will pay ' +
|
|
62
|
+
'to fund its app wallet.',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** A secret file this MCP may READ: regular file, not a symlink, ours, 0600-ish. */
|
|
66
|
+
export function assertSafeSecretFile(path) {
|
|
67
|
+
const st = lstatOrNull(path);
|
|
68
|
+
if (!st)
|
|
69
|
+
throw new Error(`${path} does not exist`);
|
|
70
|
+
if (st.isSymbolicLink())
|
|
71
|
+
throw new Error(`${path} is a symlink — refusing to use it`);
|
|
72
|
+
if (!st.isFile())
|
|
73
|
+
throw new Error(`${path} is not a regular file`);
|
|
74
|
+
const uid = myUid();
|
|
75
|
+
if (uid !== undefined && st.uid !== uid)
|
|
76
|
+
throw new Error(`${path} is not owned by the current user`);
|
|
77
|
+
if ((st.mode & 0o077) !== 0)
|
|
78
|
+
throw new Error(`${path} is readable by other users; run chmod 600 on it first`);
|
|
79
|
+
}
|
|
80
|
+
// ─── Profile ─────────────────────────────────────────────────────────────────
|
|
81
|
+
export const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
|
82
|
+
const AVATAR_TYPES = {
|
|
83
|
+
'.png': { mime: 'image/png', magic: (b) => b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
84
|
+
'.jpg': { mime: 'image/jpeg', magic: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
85
|
+
'.jpeg': { mime: 'image/jpeg', magic: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
|
|
86
|
+
'.gif': { mime: 'image/gif', magic: (b) => b.subarray(0, 4).toString('latin1') === 'GIF8' },
|
|
87
|
+
'.webp': { mime: 'image/webp', magic: (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP' },
|
|
88
|
+
};
|
|
89
|
+
/** Where the operator puts the agent's avatar. Null when none is configured / present. */
|
|
90
|
+
export function avatarPath(dir = DEFAULT_OWNER_KEY_DIR) {
|
|
91
|
+
const env = process.env.DFM_AGENT_AVATAR_PATH?.trim();
|
|
92
|
+
if (env)
|
|
93
|
+
return env;
|
|
94
|
+
for (const ext of Object.keys(AVATAR_TYPES)) {
|
|
95
|
+
const p = join(dir, `avatar${ext}`);
|
|
96
|
+
if (lstatOrNull(p))
|
|
97
|
+
return p;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Read the avatar ONLY if it is a real image: allowed extension, not a symlink,
|
|
103
|
+
* a regular file ≤ 2 MB whose first bytes match that image type. Nothing else
|
|
104
|
+
* is ever sent (the server re-checks magic bytes too).
|
|
105
|
+
*/
|
|
106
|
+
export function loadAvatar(path) {
|
|
107
|
+
const type = AVATAR_TYPES[extname(path).toLowerCase()];
|
|
108
|
+
if (!type)
|
|
109
|
+
throw new Error('avatar must be a .png, .jpg, .jpeg, .webp or .gif file');
|
|
110
|
+
// O_NOFOLLOW + fstat on the OPENED fd: no symlink, and no swap between the
|
|
111
|
+
// check and the read (review S14).
|
|
112
|
+
let fd;
|
|
113
|
+
try {
|
|
114
|
+
fd = openSync(path, fsc.O_RDONLY | fsc.O_NOFOLLOW);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
const code = e.code;
|
|
118
|
+
if (code === 'ENOENT')
|
|
119
|
+
throw new Error(`avatar file ${path} does not exist`);
|
|
120
|
+
if (code === 'ELOOP' || code === 'EMLINK')
|
|
121
|
+
throw new Error(`avatar ${path} must be a regular file (no symlinks)`);
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
let bytes;
|
|
125
|
+
try {
|
|
126
|
+
const st = fstatSync(fd);
|
|
127
|
+
if (!st.isFile())
|
|
128
|
+
throw new Error(`avatar ${path} must be a regular file (no symlinks)`);
|
|
129
|
+
if (st.size > MAX_AVATAR_BYTES)
|
|
130
|
+
throw new Error('avatar is larger than 2 MB');
|
|
131
|
+
bytes = Buffer.alloc(st.size);
|
|
132
|
+
let off = 0;
|
|
133
|
+
while (off < st.size) {
|
|
134
|
+
const n = readSync(fd, bytes, off, st.size - off, off);
|
|
135
|
+
if (n === 0)
|
|
136
|
+
break;
|
|
137
|
+
off += n;
|
|
138
|
+
}
|
|
139
|
+
bytes = bytes.subarray(0, off);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
if (!type.magic(bytes))
|
|
145
|
+
throw new Error('avatar content is not the image type its extension says');
|
|
146
|
+
return { bytes, filename: `avatar${extname(path).toLowerCase()}`, mime: type.mime };
|
|
147
|
+
}
|
|
148
|
+
/** The API's gate rule (agent-onboarding.ts), mirrored: a chosen name AND an avatar. */
|
|
149
|
+
export function profileGap(wallet, profile) {
|
|
150
|
+
const missing = [];
|
|
151
|
+
const name = profile?.displayName?.trim() ?? '';
|
|
152
|
+
const auto = wallet ? `${wallet.slice(0, 4)}...${wallet.slice(-4)}` : null;
|
|
153
|
+
if (!name || name === auto)
|
|
154
|
+
missing.push('displayName');
|
|
155
|
+
if (!profile?.effectiveAvatarUri)
|
|
156
|
+
missing.push('avatar');
|
|
157
|
+
return missing;
|
|
158
|
+
}
|
|
159
|
+
export const DEFAULT_KEY_TTL_MS = 30 * 24 * 3600 * 1000;
|
|
160
|
+
export const MAX_KEY_TTL_MS = 90 * 24 * 3600 * 1000;
|
|
161
|
+
const WRITE_FIELDS = ['perOpNotionalUsdc', 'dailyNotionalUsdc', 'allowedOps', 'destinationWhitelist', 'allowedPrograms', 'allowArbitraryPrograms'];
|
|
162
|
+
/**
|
|
163
|
+
* The exact `POST /agent/keys` body (CreateKeyDto). Review S12: a model can be
|
|
164
|
+
* prompt-injected, so the MCP mints READ-ONLY keys only; a write key (or any
|
|
165
|
+
* write cap) needs the OPERATOR's `DFM_ALLOW_WRITE_KEY_MINT=1`. Every key
|
|
166
|
+
* expires: default now + 30 days, never more than 90.
|
|
167
|
+
*/
|
|
168
|
+
export function buildCreateKeyBody(input, now = Date.now()) {
|
|
169
|
+
const wantsWrite = input.readOnly === false || WRITE_FIELDS.some((k) => input[k] !== undefined);
|
|
170
|
+
if (wantsWrite && process.env.DFM_ALLOW_WRITE_KEY_MINT?.trim() !== '1') {
|
|
171
|
+
throw new Error('refusing to mint a write key from the MCP: only read-only keys, unless the operator sets DFM_ALLOW_WRITE_KEY_MINT=1');
|
|
172
|
+
}
|
|
173
|
+
const expiresMs = input.expiresAt ? Date.parse(input.expiresAt) : now + DEFAULT_KEY_TTL_MS;
|
|
174
|
+
if (!Number.isFinite(expiresMs) || expiresMs <= now)
|
|
175
|
+
throw new Error('expiresAt must be a future ISO-8601 date');
|
|
176
|
+
if (expiresMs > now + MAX_KEY_TTL_MS)
|
|
177
|
+
throw new Error('expiresAt may be at most 90 days ahead');
|
|
178
|
+
const policy = { readOnly: input.readOnly === false ? false : true };
|
|
179
|
+
for (const k of WRITE_FIELDS)
|
|
180
|
+
if (input[k] !== undefined)
|
|
181
|
+
policy[k] = input[k];
|
|
182
|
+
return { label: input.name, policy, expiresAt: new Date(expiresMs).toISOString() };
|
|
183
|
+
}
|
|
184
|
+
export const DEFAULT_API_KEYS_DIR = join(DEFAULT_OWNER_KEY_DIR, 'api-keys');
|
|
185
|
+
/** Directory + name checks, run BEFORE minting (review S18): a key is never minted into nowhere. */
|
|
186
|
+
export function prepareKeyStore(name, dir = DEFAULT_API_KEYS_DIR) {
|
|
187
|
+
if (!lstatOrNull(dir)) {
|
|
188
|
+
assertSafeDir(dirname(dir));
|
|
189
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
190
|
+
}
|
|
191
|
+
assertSafeDir(dir);
|
|
192
|
+
return { dir, safeName: name.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 40) || 'key' };
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Write a freshly minted key to `<dir>/<name>-<prefix>.key` (dir 0700, file
|
|
196
|
+
* 0600, `wx`) and return everything EXCEPT the plaintext: the key never
|
|
197
|
+
* enters the chat, so a prompt cannot make the agent paste it anywhere.
|
|
198
|
+
*/
|
|
199
|
+
export function storeApiKey(name, issued, dir = DEFAULT_API_KEYS_DIR) {
|
|
200
|
+
const key = issued.key;
|
|
201
|
+
const prefix = String(issued.keyPrefix ?? '');
|
|
202
|
+
if (typeof key !== 'string' || !key.startsWith('dfm_') || !/^dfm_[A-Za-z0-9_-]{1,16}$/.test(prefix)) {
|
|
203
|
+
throw new Error('the API returned no dfm_ key');
|
|
204
|
+
}
|
|
205
|
+
const { safeName } = prepareKeyStore(name, dir);
|
|
206
|
+
const path = join(dir, `${safeName}-${prefix}.key`);
|
|
207
|
+
writeFileSync(path, `${key}\n`, { mode: 0o600, flag: 'wx' });
|
|
208
|
+
const { key: _plaintext, ...rest } = issued;
|
|
209
|
+
return {
|
|
210
|
+
...rest,
|
|
211
|
+
path,
|
|
212
|
+
note: `Stored at ${path} (0600). This key never enters the chat; point the other process at that file (e.g. DFM_API_KEY=$(cat ${path})).`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// ─── onboard ────────────────────────────────────────────────────────────────
|
|
216
|
+
/**
|
|
217
|
+
* The whole create_api_key: check the store, mint, store; if storing fails
|
|
218
|
+
* after the mint, REVOKE the key so no live credential is left unrecorded.
|
|
219
|
+
*/
|
|
220
|
+
export async function mintAndStoreKey(input, api, dir = DEFAULT_API_KEYS_DIR) {
|
|
221
|
+
const body = buildCreateKeyBody(input);
|
|
222
|
+
prepareKeyStore(input.name, dir);
|
|
223
|
+
const issued = await api.createApiKey(body);
|
|
224
|
+
try {
|
|
225
|
+
return storeApiKey(input.name, issued, dir);
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
const id = typeof issued.id === 'string' ? issued.id : null;
|
|
229
|
+
const revoked = id ? await api.revokeApiKey(id).then(() => true, () => false) : false;
|
|
230
|
+
throw new Error(`the key was minted but could not be stored (${e.message}); ` +
|
|
231
|
+
(revoked ? 'it was revoked, nothing is left live' : `REVOKE IT NOW (id ${id ?? 'unknown'}, prefix ${String(issued.keyPrefix ?? '?')})`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Run the steps in order and stop at the first one that needs the operator.
|
|
236
|
+
* Never mints an API key (that grants authority — the agent asks for it explicitly).
|
|
237
|
+
*/
|
|
238
|
+
export async function runOnboard(deps) {
|
|
239
|
+
const identity = deps.identity();
|
|
240
|
+
if (deps.assertSameIdentity) {
|
|
241
|
+
try {
|
|
242
|
+
await deps.assertSameIdentity();
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
return { done: false, missingStep: 'identity_mismatch', identity, error: e.message, order: ONBOARDING_ORDER };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let profile;
|
|
249
|
+
try {
|
|
250
|
+
profile = await deps.getProfile();
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
return { done: false, missingStep: 'login', identity, error: e.message, order: ONBOARDING_ORDER };
|
|
254
|
+
}
|
|
255
|
+
const missing = profileGap(profile?.wallet ?? identity.publicKey, profile);
|
|
256
|
+
if (missing.length > 0) {
|
|
257
|
+
return {
|
|
258
|
+
done: false,
|
|
259
|
+
missingStep: 'set_profile',
|
|
260
|
+
missing,
|
|
261
|
+
identity,
|
|
262
|
+
next: 'Run set_profile {displayName} with an avatar image at DFM_AGENT_AVATAR_PATH or ~/.config/dfm/avatar.png.',
|
|
263
|
+
order: ONBOARDING_ORDER,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
const appWallet = await deps.createAppWallet();
|
|
267
|
+
return {
|
|
268
|
+
done: true,
|
|
269
|
+
identity,
|
|
270
|
+
appWallet,
|
|
271
|
+
next: 'Optional: create_api_key {name} for other processes. Fund the app wallet with USDC + a little SOL.',
|
|
272
|
+
order: ONBOARDING_ORDER,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=onboarding.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfm-fi/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1916",
|
|
4
4
|
"description": "DFM v2 MCP server — drive the full fund launch → deposit → redeem (+ manage) lifecycle from an AI agent against the live API.",
|
|
5
|
-
"homepage": "https://app.dfm.finance",
|
|
5
|
+
"homepage": "https://app.dfm.finance/docs/agent",
|
|
6
|
+
"mcpName": "io.github.DFM-Finance/dfm-agent",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/DFM-Finance/dfm-agent.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/DFM-Finance/dfm-agent/issues"
|
|
13
|
+
},
|
|
6
14
|
"type": "module",
|
|
7
15
|
"bin": {
|
|
8
16
|
"dfm-agent": "dist/cli.js"
|
|
@@ -47,7 +55,7 @@
|
|
|
47
55
|
"lint": "eslint .",
|
|
48
56
|
"example:read": "tsx src/examples/list-and-status.ts",
|
|
49
57
|
"example:lifecycle": "tsx src/examples/launch-deposit-redeem.ts",
|
|
50
|
-
"test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts"
|
|
58
|
+
"test": "npm run build && node scripts/smoke-signing.mjs && node scripts/smoke-signing-v1.mjs &&node scripts/smoke-cli.mjs && node scripts/smoke-keyless.mjs && node scripts/smoke-zap-options.mjs && tsx src/who-pays.test.ts && tsx src/wallet-identity.test.ts && tsx src/money-path.test.ts && tsx src/control-flow.test.ts && tsx src/create-flow.metadata.test.ts && tsx src/manager-flow.reconfigure.test.ts && tsx src/recovery-flow.deadleg.test.ts && tsx src/zap-v2-flow.pending.test.ts && tsx src/mcp-server.confirm-gate.test.ts && tsx src/app-wallet-owner.test.ts && tsx src/onboarding.test.ts"
|
|
51
59
|
},
|
|
52
60
|
"dependencies": {
|
|
53
61
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
package/skills.md
CHANGED
|
@@ -129,12 +129,16 @@ BE PROACTIVE ABOUT THE NEXT STEP, NOT ABOUT NEW SCOPE. Finish the job the person
|
|
|
129
129
|
|
|
130
130
|
## Tools
|
|
131
131
|
|
|
132
|
-
Kim's in-app registry exposes **
|
|
132
|
+
Kim's in-app registry exposes **44 tools** (24 READ + 20 WRITE). External agents mount the official MCP and MUST treat its runtime `listTools` result as the available subset; the two surfaces intentionally are not assumed to be identical. READ tools are free and touch no funds; WRITE tools move only the caller's own funds or administered vaults.
|
|
133
133
|
|
|
134
|
-
**READ (
|
|
134
|
+
**READ (24)** — safe, no funds, no gate:
|
|
135
135
|
- `whoami` — The user's display name, primary DFM app wallet, separate sign-in/funding wallet, cluster, and write status. Call FIRST in a session; when an app wallet exists, do not present the sign-in wallet as their DFM wallet.
|
|
136
136
|
- `app_wallet` — The user's APP WALLET (operating/sub-wallet): whether they have one, its status, its LIVE onchain SOL + USDC, and what they must do next. Call this before telling a user why an autonomous action was refused — the usual reason is no wallet, not activated, or an empty float, and each has a different fix.
|
|
137
137
|
- `create_app_wallet` — Get the user's APP WALLET created. In the app it is created automatically at sign-in and approved in the browser (no key leaves the user); this returns the current app wallet state and, if there is none yet, exactly how to get it. Headless agents use the MCP tool of the same name, which creates a wallet owned by a key generated on their own machine. Moves no funds.
|
|
138
|
+
- `set_profile` — Explain how to set the user's DFM profile (display name, @username, bio, avatar). In the app it is edited on the Profile page; headless agents use the MCP tool of the same name. A chosen name and an avatar are required before an agent can create its own app wallet. Changes nothing.
|
|
139
|
+
- `create_api_key` — Explain how to get a dfm_ API key for an external agent. Keys are minted by the owner (signed-in session) in Settings > API keys or via the MCP tool of the same name; read-only by default, optional caps and expiry, shown once. Kim never mints a key. Changes nothing.
|
|
140
|
+
- `create_identity_wallet` — Explain the agent's IDENTITY wallet: the local Solana keypair a headless agent signs in with (SIWS). The MCP tool of the same name reuses an existing one or creates it once on the agent machine; in the app the user's connected wallet is the identity. Kim never creates keys. Changes nothing.
|
|
141
|
+
- `onboard` — Explain the headless-agent onboarding order over MCP: identity wallet, SIWS login, profile (name + avatar), app wallet, API key (optional). The MCP tool of the same name runs the steps and reports the missing one. Changes nothing.
|
|
138
142
|
- `list_app_wallet_assets` — EVERY token the app wallet holds — fund shares included — with amount, decimals, USD value and whether the mint is one of our fund shares. `app_wallet` answers USDC and SOL only (that is what the float is), so it cannot see a single share. Use this when the user asks what they hold, or before proposing a withdrawal.
|
|
139
143
|
- `list_dtfs` — Browse live funds (optional category filter).
|
|
140
144
|
- `get_vault` — One vault: weights, deviations, fees, TVL, NAV.
|