@onride/agent 0.0.0-stage → 0.5.1-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/account_balances.js +435 -0
- package/dist/account_reads.js +199 -0
- package/dist/agent/adapter.js +115 -0
- package/dist/agent/analytics.js +174 -0
- package/dist/agent/backend.js +237 -0
- package/dist/agent/card_hosts.js +9 -0
- package/dist/agent/contracts.js +276 -0
- package/dist/agent/conversational.js +86 -0
- package/dist/agent/core_bridge.js +1155 -0
- package/dist/agent/deposits.js +309 -0
- package/dist/agent/discovery.js +205 -0
- package/dist/agent/domain.js +507 -0
- package/dist/agent/firebase.js +34 -0
- package/dist/agent/funding.js +230 -0
- package/dist/agent/http.js +40 -0
- package/dist/agent/human.js +298 -0
- package/dist/agent/human_handoffs.js +39 -0
- package/dist/agent/ledger.js +137 -0
- package/dist/agent/mcp.js +245 -0
- package/dist/agent/order_rules.js +61 -0
- package/dist/agent/perps_account.js +41 -0
- package/dist/agent/prediction.js +607 -0
- package/dist/agent/prediction_equity_history.js +910 -0
- package/dist/agent/prediction_history.js +336 -0
- package/dist/agent/preferences.js +114 -0
- package/dist/agent/repository.js +106 -0
- package/dist/agent/runner.js +683 -0
- package/dist/agent/service.js +1347 -0
- package/dist/backend.js +6 -0
- package/dist/bridge.js +6 -0
- package/dist/cli/clients.js +99 -0
- package/dist/cli/commands.js +332 -0
- package/dist/cli/onboarding.js +53 -0
- package/dist/cli/session.js +224 -0
- package/dist/cli/setup.js +256 -0
- package/dist/cli.js +16 -0
- package/dist/guide.js +92 -0
- package/dist/http_security.js +79 -0
- package/dist/mcp-cli.js +7 -0
- package/dist/oauth.js +1032 -0
- package/dist/server.js +171 -0
- package/dist/start.js +18 -0
- package/dist/version.js +25 -0
- package/docs/confirmation-cards.md +118 -0
- package/docs/conversation-v050.md +30 -0
- package/docs/conversational-v17.md +48 -0
- package/docs/deployment.md +58 -0
- package/docs/perps-leverage.md +43 -0
- package/docs/prediction-history.md +28 -0
- package/docs/preferences-funding-v042.md +17 -0
- package/docs/ride-agent-migration.md +127 -0
- package/docs/v17-completion-checklist.md +31 -0
- package/docs/v17-rollout.md +44 -0
- package/docs/widget-account-v043.md +17 -0
- package/package.json +75 -4
- package/public/agent.html +2313 -0
- package/public/confirm.html +2373 -0
- package/public/fund.html +563 -0
- package/skills/ride/SKILL.md +18 -0
- package/skills/ride/references/commands.md +47 -0
- package/skills/ride/references/workflow.md +47 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { AgentError, result } from "./contracts.js";
|
|
2
|
+
import { D, cash, fingerprint, now, uid } from "./domain.js";
|
|
3
|
+
function owned(s, depositId) {
|
|
4
|
+
const d = s.deposits?.find((d) => d.id === depositId);
|
|
5
|
+
if (!d)
|
|
6
|
+
throw new AgentError("NOT_FOUND", "Funding request not found for this account.", false, 404);
|
|
7
|
+
if (d.provider !== "wallet")
|
|
8
|
+
throw new AgentError("UNSUPPORTED_ACTIVATION", "Prediction bridge funds are credited by the provider; check verified trading balance.");
|
|
9
|
+
return d;
|
|
10
|
+
}
|
|
11
|
+
const pending = (f) => f && ["submitting", "submitted", "uncertain"].includes(f.status);
|
|
12
|
+
// Signing challenges stay on the authenticated human surface, never in MCP.
|
|
13
|
+
export class FundingService {
|
|
14
|
+
repository;
|
|
15
|
+
adapter;
|
|
16
|
+
base;
|
|
17
|
+
constructor(repository, adapter, base) {
|
|
18
|
+
this.repository = repository;
|
|
19
|
+
this.adapter = adapter;
|
|
20
|
+
this.base = base;
|
|
21
|
+
}
|
|
22
|
+
async call(operation, user, input) {
|
|
23
|
+
if (!this.adapter.fundingOperation)
|
|
24
|
+
throw new AgentError("FUNDING_UNAVAILABLE", "Funding activation is unavailable on this deployment.", true, 503);
|
|
25
|
+
return this.adapter.fundingOperation(operation, user, input);
|
|
26
|
+
}
|
|
27
|
+
view(d, human = false) {
|
|
28
|
+
const f = d.funding;
|
|
29
|
+
return result({
|
|
30
|
+
funding: {
|
|
31
|
+
id: f.id,
|
|
32
|
+
deposit_id: d.id,
|
|
33
|
+
amount_usdc: f.amount_usdc,
|
|
34
|
+
status: f.status,
|
|
35
|
+
expires_at: f.expires_at,
|
|
36
|
+
preview_hash: f.preview_hash,
|
|
37
|
+
account_suffix: d.address.slice(-6),
|
|
38
|
+
network: "Arbitrum",
|
|
39
|
+
asset: "USDC",
|
|
40
|
+
destination: "Your Hyperliquid trading account",
|
|
41
|
+
confirmation_url: new URL(`/agent/fund/${d.id}`, this.base).href,
|
|
42
|
+
transaction_hash: f.transaction_hash ?? null,
|
|
43
|
+
receipt_status: f.receipt_status ?? null,
|
|
44
|
+
...(human
|
|
45
|
+
? {
|
|
46
|
+
requires_signature: !!f.quote.requires_signature,
|
|
47
|
+
signing_wallet: f.quote.wallet.master,
|
|
48
|
+
signing_challenge: f.quote.typed_data,
|
|
49
|
+
activation: f.activation
|
|
50
|
+
? {
|
|
51
|
+
...f.activation,
|
|
52
|
+
quote: undefined,
|
|
53
|
+
requires_signature: !!f.activation.quote.requires_signature,
|
|
54
|
+
signing_challenge: f.activation.quote.typed_data,
|
|
55
|
+
}
|
|
56
|
+
: null,
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
},
|
|
60
|
+
}, f.status === "requires_confirmation"
|
|
61
|
+
? "requires_confirmation"
|
|
62
|
+
: f.status === "credited"
|
|
63
|
+
? "completed"
|
|
64
|
+
: f.status === "uncertain"
|
|
65
|
+
? "uncertain"
|
|
66
|
+
: "queued");
|
|
67
|
+
}
|
|
68
|
+
async prepare(user, depositId, amount) {
|
|
69
|
+
return this.repository.transact(user, async (s) => {
|
|
70
|
+
const d = owned(s, depositId);
|
|
71
|
+
const blocking = s.deposits?.find((x) => pending(x.funding));
|
|
72
|
+
if (blocking) {
|
|
73
|
+
if (blocking.id === depositId)
|
|
74
|
+
return this.view(blocking);
|
|
75
|
+
throw new AgentError("FUNDING_PENDING", "An earlier funding submission is unresolved. Check its status before preparing another.");
|
|
76
|
+
}
|
|
77
|
+
if (d.funding?.status === "requires_confirmation" &&
|
|
78
|
+
d.funding.expires_at > now() &&
|
|
79
|
+
D(d.funding.amount_usdc).eq(amount))
|
|
80
|
+
return this.view(d);
|
|
81
|
+
const quote = await this.call("quote", user, {
|
|
82
|
+
amount_usdc: cash(amount),
|
|
83
|
+
expected_master: d.address,
|
|
84
|
+
});
|
|
85
|
+
if (!quote.wallet?.master ||
|
|
86
|
+
quote.wallet.master.toLowerCase() !== d.address.toLowerCase() ||
|
|
87
|
+
!D(quote.amount_usdc).eq(amount))
|
|
88
|
+
throw new AgentError("FUNDING_QUOTE_CHANGED", "Funding quote does not match the owned receiving account and selected amount.");
|
|
89
|
+
const expires = Math.min(now() + 5 * 60_000, quote.permit_deadline ? quote.permit_deadline * 1000 : Infinity);
|
|
90
|
+
if (expires <= now())
|
|
91
|
+
throw new AgentError("FUNDING_QUOTE_EXPIRED", "Review a fresh funding quote.");
|
|
92
|
+
const observed = await this.adapter.depositStatus?.(d, user);
|
|
93
|
+
if (observed?.trading_balance_usdc == null)
|
|
94
|
+
throw new AgentError("TRADING_BALANCE_UNAVAILABLE", "Verify current trading balance before preparing funding.", true);
|
|
95
|
+
quote.baseline_trading_available_usdc = cash(observed.trading_balance_usdc);
|
|
96
|
+
d.funding = {
|
|
97
|
+
id: uid("funding"),
|
|
98
|
+
amount_usdc: cash(amount),
|
|
99
|
+
created_at: now(),
|
|
100
|
+
expires_at: expires,
|
|
101
|
+
status: "requires_confirmation",
|
|
102
|
+
quote,
|
|
103
|
+
preview_hash: fingerprint({
|
|
104
|
+
deposit_id: d.id,
|
|
105
|
+
amount: cash(amount),
|
|
106
|
+
quote,
|
|
107
|
+
expires,
|
|
108
|
+
}),
|
|
109
|
+
};
|
|
110
|
+
return this.view(d);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async read(user, depositId) {
|
|
114
|
+
return this.repository.transact(user, async (s) => {
|
|
115
|
+
const d = owned(s, depositId);
|
|
116
|
+
if (!d.funding)
|
|
117
|
+
throw new AgentError("FUNDING_NOT_PREPARED", "Choose the exact amount with prepare_deposit first.");
|
|
118
|
+
return this.view(d, true);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async confirm(user, depositId, hash, signature) {
|
|
122
|
+
// Persist consumption before network I/O. A timeout or process restart
|
|
123
|
+
// cannot roll it back and make the same money action executable again.
|
|
124
|
+
const selected = await this.repository.transact(user, async (s) => {
|
|
125
|
+
const d = owned(s, depositId), f = d.funding;
|
|
126
|
+
if (!f)
|
|
127
|
+
throw new AgentError("NOT_FOUND", "Funding quote unavailable.");
|
|
128
|
+
if (f.preview_hash !== hash)
|
|
129
|
+
throw new AgentError("FUNDING_CHANGED", "Displayed funding parameters changed.");
|
|
130
|
+
if (f.status !== "requires_confirmation")
|
|
131
|
+
return { done: this.view(d, true) };
|
|
132
|
+
if (f.expires_at <= now())
|
|
133
|
+
throw new AgentError("FUNDING_QUOTE_EXPIRED", "Review a fresh funding quote.");
|
|
134
|
+
if (f.quote.requires_signature && !signature)
|
|
135
|
+
throw new AgentError("SIGNATURE_REQUIRED", "Confirm the wallet signature in Ride.");
|
|
136
|
+
f.status = "submitting";
|
|
137
|
+
return { funding: structuredClone(f), address: d.address };
|
|
138
|
+
});
|
|
139
|
+
if (selected.done)
|
|
140
|
+
return selected.done;
|
|
141
|
+
const f = selected.funding;
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await this.call("submit", user, {
|
|
145
|
+
amount_usdc: f.amount_usdc,
|
|
146
|
+
expected_master: selected.address,
|
|
147
|
+
request_id: f.id,
|
|
148
|
+
user_confirmed: true,
|
|
149
|
+
signature,
|
|
150
|
+
permit_nonce: f.quote.permit_nonce,
|
|
151
|
+
permit_deadline: f.quote.permit_deadline,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// No automatic retry, including a 4xx after a possible broadcast.
|
|
156
|
+
response = null;
|
|
157
|
+
}
|
|
158
|
+
return this.repository.transact(user, async (s) => {
|
|
159
|
+
const d = owned(s, depositId);
|
|
160
|
+
if (d.funding?.id !== f.id)
|
|
161
|
+
throw new AgentError("FUNDING_CHANGED", "Funding identity changed.");
|
|
162
|
+
if (response?.submitted &&
|
|
163
|
+
/^0x[a-fA-F0-9]{64}$/.test(response.transaction_hash)) {
|
|
164
|
+
d.funding.status =
|
|
165
|
+
response.receipt_status === "0x0" ? "failed" : "submitted";
|
|
166
|
+
d.funding.transaction_hash = response.transaction_hash;
|
|
167
|
+
d.funding.receipt_status = response.receipt_status;
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
d.funding.status = "uncertain";
|
|
171
|
+
return this.view(d, true);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async activationQuote(user, depositId) {
|
|
175
|
+
return this.repository.transact(user, async (s) => {
|
|
176
|
+
const d = owned(s, depositId);
|
|
177
|
+
if (!d.funding ||
|
|
178
|
+
!["submitted", "credited", "uncertain"].includes(d.funding.status))
|
|
179
|
+
throw new AgentError("FUNDING_NOT_SUBMITTED", "Fund this account before activating it.");
|
|
180
|
+
const status = await this.adapter.depositStatus?.(d, user);
|
|
181
|
+
if (!status || !D(status.trading_balance_usdc ?? "0").gt(0))
|
|
182
|
+
throw new AgentError("TRADING_CREDIT_PENDING", "Wait for verified trading account credit before activation.", true);
|
|
183
|
+
const quote = await this.call("activate_quote", user, {
|
|
184
|
+
expected_master: d.address,
|
|
185
|
+
});
|
|
186
|
+
const expires = now() + 5 * 60_000;
|
|
187
|
+
d.funding.activation = {
|
|
188
|
+
quote,
|
|
189
|
+
expires_at: expires,
|
|
190
|
+
status: "requires_confirmation",
|
|
191
|
+
preview_hash: fingerprint({ deposit_id: d.id, quote, expires }),
|
|
192
|
+
};
|
|
193
|
+
return this.view(d, true);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
async activate(user, depositId, hash, signature) {
|
|
197
|
+
const selected = await this.repository.transact(user, async (s) => {
|
|
198
|
+
const d = owned(s, depositId), a = d.funding?.activation;
|
|
199
|
+
if (!a || a.preview_hash !== hash || a.expires_at <= now())
|
|
200
|
+
throw new AgentError("ACTIVATION_CHANGED", "Review a fresh activation request.");
|
|
201
|
+
if (a.status !== "requires_confirmation")
|
|
202
|
+
return { done: this.view(d, true) };
|
|
203
|
+
if (a.quote.requires_signature && !signature)
|
|
204
|
+
throw new AgentError("SIGNATURE_REQUIRED", "Confirm account activation with your wallet.");
|
|
205
|
+
a.status = "submitting";
|
|
206
|
+
return { quote: structuredClone(a.quote), address: d.address };
|
|
207
|
+
});
|
|
208
|
+
if (selected.done)
|
|
209
|
+
return selected.done;
|
|
210
|
+
let response;
|
|
211
|
+
try {
|
|
212
|
+
response = await this.call("activate_submit", user, {
|
|
213
|
+
expected_master: selected.address,
|
|
214
|
+
user_confirmed: true,
|
|
215
|
+
signature,
|
|
216
|
+
nonce: selected.quote.nonce,
|
|
217
|
+
expected_agent: selected.quote.typed_data?.message?.agentAddress,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
response = null;
|
|
222
|
+
}
|
|
223
|
+
return this.repository.transact(user, async (s) => {
|
|
224
|
+
const d = owned(s, depositId);
|
|
225
|
+
d.funding.activation.status =
|
|
226
|
+
response?.wallet?.status === "active" ? "completed" : "uncertain";
|
|
227
|
+
return this.view(d, true);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AgentError, failure, inputs, mutationTools, stateTools, publicResult, TOOL_NAMES, } from "./contracts.js";
|
|
2
|
+
import { invokeAgent } from "./mcp.js";
|
|
3
|
+
import { accountCopiesInput } from "../account_reads.js";
|
|
4
|
+
export function mountAgentProxy(app, auth, backend, readOnly = false, accountRead) {
|
|
5
|
+
app.post("/agent/v1/:operation", auth, async (req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
const name = req.params.operation;
|
|
8
|
+
if (typeof name !== "string" ||
|
|
9
|
+
(!TOOL_NAMES.includes(name) &&
|
|
10
|
+
name !== "proposal" &&
|
|
11
|
+
!(accountRead && name === "get_my_copies")))
|
|
12
|
+
throw new AgentError("NOT_FOUND", "Unknown operation.", false, 404);
|
|
13
|
+
if (readOnly &&
|
|
14
|
+
(mutationTools.has(name) ||
|
|
15
|
+
(stateTools.has(name) && name !== "recommend_traders") ||
|
|
16
|
+
name === "proposal"))
|
|
17
|
+
throw new AgentError("READ_ONLY", "Trading is not enabled on this deployment.", false, 403);
|
|
18
|
+
const scope = mutationTools.has(name)
|
|
19
|
+
? "ride:trade"
|
|
20
|
+
: "ride:read";
|
|
21
|
+
if (!req.auth?.scopes.includes(scope))
|
|
22
|
+
throw new AgentError("SCOPE_REQUIRED", `Missing ${scope} permission.`, false, 403);
|
|
23
|
+
const coreToken = req.auth?.extra?.rideAuthToken;
|
|
24
|
+
if (typeof coreToken !== "string")
|
|
25
|
+
throw new AgentError("AUTH_REQUIRED", "A Ride OAuth session is required.", false, 401);
|
|
26
|
+
const args = name === "proposal"
|
|
27
|
+
? req.body
|
|
28
|
+
: name === "get_my_copies"
|
|
29
|
+
? accountCopiesInput.parse(req.body)
|
|
30
|
+
: inputs[name].parse(req.body);
|
|
31
|
+
const value = accountRead && (name === "get_portfolio" || name === "get_my_copies")
|
|
32
|
+
? await accountRead(name, args, coreToken)
|
|
33
|
+
: await invokeAgent(backend, coreToken, name, args);
|
|
34
|
+
res.set("Cache-Control", "no-store").json(publicResult(value));
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
res.status(e instanceof AgentError ? e.httpStatus : 400).json(failure(e));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual, } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { AgentError, failure, publicResult, output } from "./contracts.js";
|
|
6
|
+
import { MemoryHumanHandoffs } from "./human_handoffs.js";
|
|
7
|
+
import { claudeCardDomain } from "./card_hosts.js";
|
|
8
|
+
export function mountHumanConfirmation(app, options) {
|
|
9
|
+
if (options.cookieKey.length < 32)
|
|
10
|
+
throw new Error("Human session cookie key must contain at least 32 characters");
|
|
11
|
+
const key = createHash("sha256").update(options.cookieKey).digest(), origin = new URL(options.base).origin, handoffs = options.handoffs ?? new MemoryHumanHandoffs();
|
|
12
|
+
const proposalId = z.string().regex(/^proposal_[a-zA-Z0-9_-]{8,100}$/);
|
|
13
|
+
const browserCookie = "ride_agent_human", embedCookie = "ride_agent_embedded";
|
|
14
|
+
const requireOrigin = (req) => {
|
|
15
|
+
if (req.get("origin") !== origin)
|
|
16
|
+
throw new AgentError("ORIGIN_REQUIRED", "Ride browser origin is required.", false, 403);
|
|
17
|
+
};
|
|
18
|
+
const cookies = (req) => Object.fromEntries((req.headers.cookie ?? "").split(";").map((x) => x.trim().split("=")));
|
|
19
|
+
const seal = (v) => {
|
|
20
|
+
const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv), body = Buffer.concat([cipher.update(JSON.stringify(v)), cipher.final()]);
|
|
21
|
+
return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
|
|
22
|
+
};
|
|
23
|
+
const unseal = (v) => {
|
|
24
|
+
try {
|
|
25
|
+
const b = Buffer.from(v, "base64url"), decipher = createDecipheriv("aes-256-gcm", key, b.subarray(0, 12));
|
|
26
|
+
decipher.setAuthTag(b.subarray(12, 28));
|
|
27
|
+
const s = JSON.parse(Buffer.concat([
|
|
28
|
+
decipher.update(b.subarray(28)),
|
|
29
|
+
decipher.final(),
|
|
30
|
+
]).toString());
|
|
31
|
+
if (typeof s.token !== "string" ||
|
|
32
|
+
!s.token ||
|
|
33
|
+
typeof s.csrf !== "string" ||
|
|
34
|
+
!Number.isFinite(s.expires_at) ||
|
|
35
|
+
s.expires_at <= Date.now())
|
|
36
|
+
throw 0;
|
|
37
|
+
return s;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new AgentError("AUTH_REQUIRED", "Sign in to Ride to confirm.", false, 401);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const sessionFor = (req) => {
|
|
44
|
+
const embedded = req.get("x-ride-embedded") === "1";
|
|
45
|
+
const raw = cookies(req)[embedded ? embedCookie : browserCookie] ?? "";
|
|
46
|
+
const session = unseal(raw);
|
|
47
|
+
if (!!session.embedded !== embedded)
|
|
48
|
+
throw new AgentError("AUTH_REQUIRED", "Sign in to Ride.", false, 401);
|
|
49
|
+
return { session, raw };
|
|
50
|
+
};
|
|
51
|
+
const checkCsrf = (req, csrf) => {
|
|
52
|
+
const given = Buffer.from(req.get("x-ride-csrf") ?? ""), expected = Buffer.from(csrf);
|
|
53
|
+
if (given.length !== expected.length || !timingSafeEqual(given, expected))
|
|
54
|
+
throw new AgentError("CSRF_REQUIRED", "Refresh the Ride sign-in session.", false, 403);
|
|
55
|
+
};
|
|
56
|
+
// A same-origin read restores the anti-CSRF token without exposing the Core JWT
|
|
57
|
+
// or renewing the original 15-minute human session.
|
|
58
|
+
app.post("/agent/human/session", (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
requireOrigin(req);
|
|
61
|
+
const { session } = sessionFor(req);
|
|
62
|
+
res
|
|
63
|
+
.set("Cache-Control", "no-store")
|
|
64
|
+
.json({ csrf: session.csrf, expires_at: session.expires_at });
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
res.status(e instanceof AgentError ? e.httpStatus : 400).json(failure(e));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
app.post("/agent/human/embed-ticket", async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
requireOrigin(req);
|
|
73
|
+
if (req.get("x-ride-embedded"))
|
|
74
|
+
throw new AgentError("AUTH_REQUIRED", "Open Ride sign-in.", false, 401);
|
|
75
|
+
const { session, raw } = sessionFor(req);
|
|
76
|
+
checkCsrf(req, session.csrf);
|
|
77
|
+
const input = z
|
|
78
|
+
.object({
|
|
79
|
+
proposal_id: proposalId,
|
|
80
|
+
challenge: z.string().regex(/^[a-f0-9]{64}$/),
|
|
81
|
+
})
|
|
82
|
+
.strict()
|
|
83
|
+
.parse(req.body);
|
|
84
|
+
const ticket = await handoffs.issue({
|
|
85
|
+
...input,
|
|
86
|
+
session: raw,
|
|
87
|
+
expires_at: Math.min(session.expires_at, Date.now() + 60_000),
|
|
88
|
+
});
|
|
89
|
+
res.set("Cache-Control", "no-store").json({ ticket });
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
res.status(e instanceof AgentError ? e.httpStatus : 400).json(failure(e));
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
app.post("/agent/human/embed-session", async (req, res) => {
|
|
96
|
+
try {
|
|
97
|
+
requireOrigin(req);
|
|
98
|
+
const input = z
|
|
99
|
+
.object({
|
|
100
|
+
ticket: z.string().regex(/^[a-zA-Z0-9_-]{43}$/),
|
|
101
|
+
proposal_id: proposalId,
|
|
102
|
+
challenge: z.string().regex(/^[a-f0-9]{64}$/),
|
|
103
|
+
})
|
|
104
|
+
.strict()
|
|
105
|
+
.parse(req.body);
|
|
106
|
+
const handoff = await handoffs.consume(input.ticket);
|
|
107
|
+
if (!handoff ||
|
|
108
|
+
handoff.challenge !== input.challenge ||
|
|
109
|
+
handoff.proposal_id !== input.proposal_id)
|
|
110
|
+
throw new AgentError("AUTH_REQUIRED", "Ride sign-in handoff expired. Reopen sign-in.", false, 401);
|
|
111
|
+
const session = unseal(handoff.session);
|
|
112
|
+
const embedded = {
|
|
113
|
+
...session,
|
|
114
|
+
embedded: true,
|
|
115
|
+
csrf: randomBytes(32).toString("base64url"),
|
|
116
|
+
};
|
|
117
|
+
res.cookie(embedCookie, seal(embedded), {
|
|
118
|
+
httpOnly: true,
|
|
119
|
+
secure: true,
|
|
120
|
+
sameSite: "none",
|
|
121
|
+
partitioned: true,
|
|
122
|
+
path: "/agent",
|
|
123
|
+
maxAge: Math.max(0, session.expires_at - Date.now()),
|
|
124
|
+
});
|
|
125
|
+
res
|
|
126
|
+
.set("Cache-Control", "no-store")
|
|
127
|
+
.json({ csrf: embedded.csrf, expires_at: embedded.expires_at });
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
res.status(e instanceof AgentError ? e.httpStatus : 400).json(failure(e));
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
app.get(["/agent/card/:proposalId", "/agent/card-demo"], async (req, res) => {
|
|
134
|
+
const demo = req.path === "/agent/card-demo";
|
|
135
|
+
if (!demo && !proposalId.safeParse(req.params.proposalId).success) {
|
|
136
|
+
res.status(404).end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const template = await readFile(fileURLToPath(new URL("../../public/confirm.html", import.meta.url)), "utf8");
|
|
140
|
+
const ancestors = options.frameAncestors ?? [
|
|
141
|
+
"'self'",
|
|
142
|
+
"https://chatgpt.com",
|
|
143
|
+
"https://*.chatgpt.com",
|
|
144
|
+
"https://*.oaiusercontent.com",
|
|
145
|
+
"codex-sandbox://*.web-sandbox.oaiusercontent.com",
|
|
146
|
+
"https://claude.ai",
|
|
147
|
+
// Claude assigns each MCP endpoint its own sandbox origin. Allow only
|
|
148
|
+
// this server's origin, rather than every connector hosted by Claude.
|
|
149
|
+
`https://${claudeCardDomain(options.base)}`,
|
|
150
|
+
];
|
|
151
|
+
if (ancestors.some((x) => /[;\r\n]/.test(x)))
|
|
152
|
+
throw Error("Invalid card frame ancestor");
|
|
153
|
+
// Only the dedicated card route can be framed; existing confirmation,
|
|
154
|
+
// OAuth, funding, App and API routes retain DENY/frame-ancestors none.
|
|
155
|
+
res.removeHeader("X-Frame-Options");
|
|
156
|
+
res.set("Content-Security-Policy", `base-uri 'self'; frame-ancestors ${ancestors.join(" ")}; object-src 'none'`);
|
|
157
|
+
res
|
|
158
|
+
.set("Cache-Control", "no-store")
|
|
159
|
+
.type("html")
|
|
160
|
+
.send(template.replace("/*CONFIG*/{}", JSON.stringify({
|
|
161
|
+
proposal_id: req.params.proposalId,
|
|
162
|
+
embedded: true,
|
|
163
|
+
demo,
|
|
164
|
+
privy_app_id: options.auth.privyAppId,
|
|
165
|
+
privy_client_id: options.auth.privyClientId,
|
|
166
|
+
privy_sdk: options.auth.jsSdkUrl,
|
|
167
|
+
}).replaceAll("<", "\\u003c")));
|
|
168
|
+
});
|
|
169
|
+
app.get("/agent/confirm/:proposalId", async (req, res) => {
|
|
170
|
+
if (!/^proposal_[a-zA-Z0-9_-]{8,100}$/.test(req.params.proposalId)) {
|
|
171
|
+
res.status(404).end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const template = await readFile(fileURLToPath(new URL("../../public/confirm.html", import.meta.url)), "utf8");
|
|
175
|
+
const config = {
|
|
176
|
+
proposal_id: req.params.proposalId,
|
|
177
|
+
privy_app_id: options.auth.privyAppId,
|
|
178
|
+
privy_client_id: options.auth.privyClientId,
|
|
179
|
+
privy_sdk: options.auth.jsSdkUrl,
|
|
180
|
+
};
|
|
181
|
+
res
|
|
182
|
+
.set("Cache-Control", "no-store")
|
|
183
|
+
.type("html")
|
|
184
|
+
.send(template.replace("/*CONFIG*/{}", JSON.stringify(config).replaceAll("<", "\\u003c")));
|
|
185
|
+
});
|
|
186
|
+
app.get("/agent/fund/:depositId", async (req, res) => {
|
|
187
|
+
if (!/^deposit_[a-f0-9]{32}$/.test(req.params.depositId)) {
|
|
188
|
+
res.status(404).end();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const template = await readFile(fileURLToPath(new URL("../../public/fund.html", import.meta.url)), "utf8");
|
|
192
|
+
const config = {
|
|
193
|
+
deposit_id: req.params.depositId,
|
|
194
|
+
privy_app_id: options.auth.privyAppId,
|
|
195
|
+
privy_client_id: options.auth.privyClientId,
|
|
196
|
+
privy_sdk: options.auth.jsSdkUrl,
|
|
197
|
+
};
|
|
198
|
+
res
|
|
199
|
+
.set("Cache-Control", "no-store")
|
|
200
|
+
.type("html")
|
|
201
|
+
.send(template.replace("/*CONFIG*/{}", JSON.stringify(config).replaceAll("<", "\\u003c")));
|
|
202
|
+
});
|
|
203
|
+
app.post("/agent/human/login", async (req, res) => {
|
|
204
|
+
try {
|
|
205
|
+
if (req.get("origin") !== origin)
|
|
206
|
+
throw new AgentError("ORIGIN_REQUIRED", "Ride browser origin is required.", false, 403);
|
|
207
|
+
const input = z
|
|
208
|
+
.object({
|
|
209
|
+
privyId: z.string().min(1),
|
|
210
|
+
privyAccessToken: z.string().min(1),
|
|
211
|
+
email: z.string().optional(),
|
|
212
|
+
walletAddress: z.string().optional(),
|
|
213
|
+
privyWalletId: z.string().optional(),
|
|
214
|
+
authProvider: z.string().optional(),
|
|
215
|
+
accessCode: z.string().optional(),
|
|
216
|
+
})
|
|
217
|
+
.strict()
|
|
218
|
+
.parse(req.body);
|
|
219
|
+
const exchanged = await options.exchange(input), csrf = randomBytes(32).toString("base64url");
|
|
220
|
+
res.cookie(browserCookie, seal({
|
|
221
|
+
token: exchanged.rideAuthToken,
|
|
222
|
+
csrf,
|
|
223
|
+
expires_at: Date.now() + 15 * 60_000,
|
|
224
|
+
}), {
|
|
225
|
+
httpOnly: true,
|
|
226
|
+
secure: new URL(origin).protocol === "https:",
|
|
227
|
+
sameSite: "strict",
|
|
228
|
+
path: "/agent",
|
|
229
|
+
maxAge: 15 * 60_000,
|
|
230
|
+
});
|
|
231
|
+
res.set("Cache-Control", "no-store").json({ csrf });
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
res.status(e instanceof AgentError ? e.httpStatus : 400).json(failure(e));
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
const allowed = new Set([
|
|
238
|
+
"funding",
|
|
239
|
+
"funding_prepare",
|
|
240
|
+
"funding_confirm",
|
|
241
|
+
"funding_activation_quote",
|
|
242
|
+
"funding_activate",
|
|
243
|
+
"funding_status",
|
|
244
|
+
"proposal",
|
|
245
|
+
"confirm",
|
|
246
|
+
"reject",
|
|
247
|
+
"authorize",
|
|
248
|
+
"revoke",
|
|
249
|
+
"portfolio",
|
|
250
|
+
"updates",
|
|
251
|
+
"update",
|
|
252
|
+
]);
|
|
253
|
+
app.post("/agent/human/:operation", async (req, res) => {
|
|
254
|
+
try {
|
|
255
|
+
if (!allowed.has(req.params.operation))
|
|
256
|
+
throw new AgentError("NOT_FOUND", "Unknown human operation.", false, 404);
|
|
257
|
+
let token;
|
|
258
|
+
const authorization = req.get("authorization");
|
|
259
|
+
if (authorization) {
|
|
260
|
+
// Native Ride sends its Core JWT. The backend validates it against
|
|
261
|
+
// authenticated Core identity; MCP's opaque OAuth tokens fail there.
|
|
262
|
+
if (!authorization.startsWith("Bearer ") || !authorization.slice(7))
|
|
263
|
+
throw new AgentError("AUTH_REQUIRED", "Sign in to Ride.", false, 401);
|
|
264
|
+
token = authorization.slice(7);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
if (req.get("origin") !== origin)
|
|
268
|
+
throw new AgentError("ORIGIN_REQUIRED", "Ride browser origin is required.", false, 403);
|
|
269
|
+
const { session } = sessionFor(req);
|
|
270
|
+
checkCsrf(req, session.csrf);
|
|
271
|
+
if (session.embedded &&
|
|
272
|
+
!["proposal", "confirm", "reject"].includes(req.params.operation))
|
|
273
|
+
throw new AgentError("NOT_FOUND", "This operation is unavailable in a confirmation card.", false, 404);
|
|
274
|
+
token = session.token;
|
|
275
|
+
}
|
|
276
|
+
const response = await fetch(new URL(`/agent/human/${req.params.operation}`, options.backend), {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: {
|
|
279
|
+
"Content-Type": "application/json",
|
|
280
|
+
Authorization: `Bearer ${token}`,
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify(req.body),
|
|
283
|
+
signal: AbortSignal.timeout(req.params.operation.startsWith("funding") ? 120000 : 25000),
|
|
284
|
+
redirect: "error",
|
|
285
|
+
});
|
|
286
|
+
const body = await response.json();
|
|
287
|
+
// Verified signing challenges belong only to this authenticated human
|
|
288
|
+
// page; MCP routes continue to reject raw trading identities.
|
|
289
|
+
const value = req.params.operation.startsWith("funding")
|
|
290
|
+
? output.parse(body)
|
|
291
|
+
: publicResult(body);
|
|
292
|
+
res.status(response.status).set("Cache-Control", "no-store").json(value);
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
res.status(e instanceof AgentError ? e.httpStatus : 503).json(failure(e));
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createClient } from "redis";
|
|
3
|
+
export class MemoryHumanHandoffs {
|
|
4
|
+
values = new Map();
|
|
5
|
+
async issue(value) {
|
|
6
|
+
for (const [key, entry] of this.values)
|
|
7
|
+
if (entry.expires_at <= Date.now())
|
|
8
|
+
this.values.delete(key);
|
|
9
|
+
const ticket = randomBytes(32).toString("base64url");
|
|
10
|
+
this.values.set(ticket, value);
|
|
11
|
+
return ticket;
|
|
12
|
+
}
|
|
13
|
+
async consume(ticket) {
|
|
14
|
+
const value = this.values.get(ticket);
|
|
15
|
+
this.values.delete(ticket);
|
|
16
|
+
return value && value.expires_at > Date.now() ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function redisHumanHandoffs(url) {
|
|
20
|
+
const client = createClient({ url });
|
|
21
|
+
client.on("error", () => { });
|
|
22
|
+
await client.connect();
|
|
23
|
+
const prefix = "ride:agent:human-handoff:";
|
|
24
|
+
return {
|
|
25
|
+
async issue(value) {
|
|
26
|
+
const ticket = randomBytes(32).toString("base64url");
|
|
27
|
+
await client.set(prefix + ticket, JSON.stringify(value), { PX: 60_000 });
|
|
28
|
+
return ticket;
|
|
29
|
+
},
|
|
30
|
+
async consume(ticket) {
|
|
31
|
+
// GETDEL makes a browser handoff single-use across gateway replicas.
|
|
32
|
+
const raw = await client.getDel(prefix + ticket);
|
|
33
|
+
if (!raw)
|
|
34
|
+
return undefined;
|
|
35
|
+
const value = JSON.parse(raw);
|
|
36
|
+
return value.expires_at > Date.now() ? value : undefined;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|