@ramex-labs/continuity-remote 0.3.0-preview.1
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/BUILD-PROVENANCE.json +204 -0
- package/LICENSE +202 -0
- package/LICENSING.md +7 -0
- package/NOTICE +7 -0
- package/README.md +90 -0
- package/THIRD-PARTY-NOTICES.md +7 -0
- package/dist/remote-tools/attempts.d.mts +1 -0
- package/dist/remote-tools/attempts.mjs +1 -0
- package/dist/remote-tools/client.mjs +36 -0
- package/dist/remote-tools/destination.mjs +180 -0
- package/dist/remote-tools/durable-store.mjs +232 -0
- package/dist/remote-tools/executor.mjs +67 -0
- package/dist/remote-tools/index.d.mts +106 -0
- package/dist/remote-tools/index.mjs +7 -0
- package/dist/remote-tools/langchain.d.mts +7 -0
- package/dist/remote-tools/langchain.mjs +62 -0
- package/dist/remote-tools/local.d.mts +1 -0
- package/dist/remote-tools/local.mjs +1 -0
- package/dist/remote-tools/recovery.mjs +40 -0
- package/dist/remote-tools/runtime.d.mts +1 -0
- package/dist/remote-tools/runtime.mjs +1 -0
- package/dist/remote-tools/strict-json.mjs +136 -0
- package/dist/remote-tools/validation.mjs +96 -0
- package/dist/remote-tools/wire.mjs +150 -0
- package/examples/native-consumer.mjs +44 -0
- package/examples/walkthrough.mjs +136 -0
- package/package.json +66 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {DynamicStructuredTool} from '@langchain/core/tools';
|
|
2
|
+
import * as z from 'zod';
|
|
3
|
+
import {identifier,record,requireCondition} from '@ramex-labs/continuity/adapter';
|
|
4
|
+
|
|
5
|
+
const decimalPattern=/^(0|[1-9][0-9]{0,77})$/;
|
|
6
|
+
const decimalAmount=z.string().regex(decimalPattern,'Use whole units as decimal text, without signs, fractions, exponents or separators.')
|
|
7
|
+
.refine(value=>!decimalPattern.test(value)||BigInt(value)<(1n<<256n),
|
|
8
|
+
'Amount must be at most 2^256 - 1 whole units.')
|
|
9
|
+
.describe('Whole units as decimal text; no sign, fraction, exponent or separators.');
|
|
10
|
+
const controlFree=/^[^\u0000-\u001f\u007f]+$/;
|
|
11
|
+
const textField=z.string().min(1).max(1024).regex(controlFree,'Text cannot contain control characters.');
|
|
12
|
+
const identifierField=z.string().min(1).max(128).regex(controlFree,'Identifiers cannot contain control characters.');
|
|
13
|
+
const integerField=z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
|
|
14
|
+
.refine(value=>!Object.is(value,-0),'Use zero rather than negative zero.');
|
|
15
|
+
|
|
16
|
+
/** One application-owned business operation, exposed as an ordinary LangChain tool. */
|
|
17
|
+
export function createContinuityTool(options) {
|
|
18
|
+
const {registry,executor}=options;
|
|
19
|
+
const settings=record({tool:options.tool,operationId:options.operationId,businessKey:options.businessKey,
|
|
20
|
+
...(Object.hasOwn(options,'name')?{name:options.name}:{})},['tool','operationId','businessKey'],['name']);
|
|
21
|
+
for(const name of ['tool','operationId','businessKey'])identifier(settings[name]);
|
|
22
|
+
const definition=registry.tools.find(x=>x.id===settings.tool);
|
|
23
|
+
requireCondition(definition,'UNKNOWN_TOOL');
|
|
24
|
+
requireCondition(typeof executor.run==='function','INVALID_CONFIGURATION');
|
|
25
|
+
const name=Object.hasOwn(settings,'name')?settings.name:definition.id.replaceAll('.','_');
|
|
26
|
+
requireCondition(typeof name==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(name),'INVALID_TOOL_NAME');
|
|
27
|
+
const fields={};
|
|
28
|
+
for(const [name,type] of Object.entries(definition.fields)) {
|
|
29
|
+
fields[name]=Array.isArray(type)?z.enum(type):type==='text'?textField:
|
|
30
|
+
type==='identifier'?identifierField:type==='integer'?integerField:
|
|
31
|
+
type==='boolean'?z.boolean():decimalAmount;
|
|
32
|
+
}
|
|
33
|
+
return new DynamicStructuredTool({
|
|
34
|
+
name,
|
|
35
|
+
description:`Request ${definition.id} for one application-assigned business operation. Refusal or uncertainty does not permit a new operation ID. Report the returned status; it is not proof of an outside outcome.`,
|
|
36
|
+
schema:z.object(fields).strict(),
|
|
37
|
+
async func(input) {
|
|
38
|
+
const args={...input};
|
|
39
|
+
// JSON tool calls carry exact decimal text. Conversion is explicit here;
|
|
40
|
+
// the core/destination API still accepts only a bounded bigint amount.
|
|
41
|
+
for(const [name,type] of Object.entries(definition.fields))if(type==='amount')args[name]=BigInt(args[name]);
|
|
42
|
+
const result=await executor.run({operationId:settings.operationId,businessKey:settings.businessKey,tool:settings.tool,arguments:args});
|
|
43
|
+
return JSON.stringify({operationId:settings.operationId,status:result.execution.status,
|
|
44
|
+
lastReportedServiceState:result.serviceReport?.result.state??'REPORT_UNAVAILABLE',
|
|
45
|
+
invocation:result.execution.invocation?.status??result.execution.result?.status??null,
|
|
46
|
+
externalOutcome:result.externalOutcome,revocationBoundary:result.revocationBoundary});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build one host-selected tool list, refusing ambiguous framework names. */
|
|
52
|
+
export function createContinuityTools(options) {
|
|
53
|
+
const {registry,executor}=options;
|
|
54
|
+
const {operations}=record({operations:options.operations},['operations']);
|
|
55
|
+
requireCondition(Array.isArray(operations)&&operations.length>0&&operations.length<=32,'INVALID_TOOL_OPERATIONS');
|
|
56
|
+
const tools=operations.map(value=>{
|
|
57
|
+
const operation=record(value,['tool','operationId','businessKey'],['name']);
|
|
58
|
+
return createContinuityTool({registry,executor,...operation});
|
|
59
|
+
});
|
|
60
|
+
requireCondition(new Set(tools.map(tool=>tool.name)).size===tools.length,'DUPLICATE_TOOL_NAME');
|
|
61
|
+
return Object.freeze(tools);
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@ramex-labs/continuity/local';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@ramex-labs/continuity/local';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import * as core from '@ramex-labs/continuity/adapter';
|
|
3
|
+
import {PortableFileEventStore} from '@ramex-labs/continuity/adapter';
|
|
4
|
+
import {stateOf} from '@ramex-labs/continuity/adapter';
|
|
5
|
+
import {record,requireCondition} from '@ramex-labs/continuity/adapter';
|
|
6
|
+
|
|
7
|
+
/** Application-owned recovery. Neither method dispatches or re-prepares work. */
|
|
8
|
+
export function createCooperativeRecovery({local,client,registry}) {
|
|
9
|
+
const store=new PortableFileEventStore(local.historyFile);
|
|
10
|
+
const select=input=>{
|
|
11
|
+
const value=record(input,['operationId','businessKey','tool','arguments']);
|
|
12
|
+
const selected=registry.capture({intentId:value.operationId,businessKey:value.businessKey,
|
|
13
|
+
tool:value.tool,arguments:value.arguments,contractId:registry.contractId});
|
|
14
|
+
const state=stateOf(store.readAll());
|
|
15
|
+
requireCondition(core.canonicalEncode(state.genesis.domain)===core.canonicalEncode(local.domain),'DOMAIN_MISMATCH');
|
|
16
|
+
const admission=state.intentAdmissions.get(value.operationId),intent=state.intentDeclarations.get(value.operationId)?.data;
|
|
17
|
+
requireCondition(admission&&intent,'ADMISSION_REQUIRED');
|
|
18
|
+
requireCondition(intent.action===selected.action&&intent.resource===selected.resource&&intent.termsCommitment===selected.termsCommitment,'OPERATION_CONFLICT');
|
|
19
|
+
requireCondition(intent.amount===selected.quantities.amount&&intent.counterpartyId===selected.quantities.counterparty,'OPERATION_CONFLICT');
|
|
20
|
+
return {selected,identity:admission.adapterIdentity};
|
|
21
|
+
};
|
|
22
|
+
const bind=(reply,{selected,identity})=>{
|
|
23
|
+
let report=reply.result;
|
|
24
|
+
if(report.state==='TOO_LATE')report=report.report;
|
|
25
|
+
requireCondition(report.key===identity.idempotencyKey,'REPORT_MISMATCH');
|
|
26
|
+
if(['APPLIED','CANCELLED','PENDING'].includes(report.state)) {
|
|
27
|
+
requireCondition(report.fingerprint===identity.submissionFingerprint&&report.intentId===selected.wire.intentId&&
|
|
28
|
+
report.tool===selected.wire.tool&&report.businessKey===selected.wire.businessKey&&report.contractId===registry.contractId,'REPORT_MISMATCH');
|
|
29
|
+
} else requireCondition(report.state==='UNKNOWN','REPORT_UNAVAILABLE');
|
|
30
|
+
const observationAcknowledgment=report.state==='APPLIED'
|
|
31
|
+
?core.createRemoteServiceReportAcknowledgment(identity,'0x'+createHash('sha256').update(core.canonicalEncode(report)).digest('hex')):null;
|
|
32
|
+
return Object.freeze({serviceReport:reply,observationAcknowledgment,externalOutcome:'NOT_PROVEN',
|
|
33
|
+
dispatchPerformed:false,retryPolicy:'NO_AUTOMATIC_REDELIVERY'});
|
|
34
|
+
};
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
async lookup(input){const selected=select(input);return bind(await client.status(selected.identity.idempotencyKey),selected);},
|
|
37
|
+
// Coordinator privilege: expose this only to the application's operator.
|
|
38
|
+
async cancel(input){const selected=select(input);return bind(await client.cancel(selected.identity.idempotencyKey),selected);},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@ramex-labs/continuity/runtime';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@ramex-labs/continuity/runtime';
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Validate raw JSON before the SDK parser can discard duplicate decoded keys.
|
|
2
|
+
// The explicit stack also makes deeply nested, bounded input independent of the JS call stack.
|
|
3
|
+
export class StrictJsonError extends Error {
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(code);
|
|
6
|
+
this.name = "StrictJsonError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function parseStrictJson(
|
|
12
|
+
bytes,
|
|
13
|
+
{ maxBytes = 2 * 1024 * 1024, maxDepth = 64, maxNodes = 100000 } = {},
|
|
14
|
+
) {
|
|
15
|
+
if (bytes.byteLength > maxBytes)
|
|
16
|
+
throw new StrictJsonError("JSON_BYTES_EXCEEDED");
|
|
17
|
+
let source;
|
|
18
|
+
try {
|
|
19
|
+
source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
20
|
+
bytes,
|
|
21
|
+
);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new StrictJsonError("INVALID_UTF8");
|
|
24
|
+
}
|
|
25
|
+
let at = 0;
|
|
26
|
+
const stack = [];
|
|
27
|
+
let nodes = 0;
|
|
28
|
+
const bad = () => {
|
|
29
|
+
throw new StrictJsonError("INVALID_JSON");
|
|
30
|
+
};
|
|
31
|
+
const space = () => {
|
|
32
|
+
while (/[\x20\t\r\n]/.test(source[at] ?? "\0")) at++;
|
|
33
|
+
};
|
|
34
|
+
const string = () => {
|
|
35
|
+
const start = at++;
|
|
36
|
+
while (at < source.length) {
|
|
37
|
+
const c = source.charCodeAt(at++);
|
|
38
|
+
if (c === 34) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(source.slice(start, at));
|
|
41
|
+
} catch {
|
|
42
|
+
bad();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (c < 32) bad();
|
|
46
|
+
if (c === 92) {
|
|
47
|
+
const escape = source[at++];
|
|
48
|
+
if (escape === "u") {
|
|
49
|
+
if (!/^[0-9a-fA-F]{4}$/.test(source.slice(at, at + 4))) bad();
|
|
50
|
+
at += 4;
|
|
51
|
+
} else if (!['"', "\\", "/", "b", "f", "n", "r", "t"].includes(escape))
|
|
52
|
+
bad();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
bad();
|
|
56
|
+
};
|
|
57
|
+
const value = () => {
|
|
58
|
+
if (++nodes > maxNodes) throw new StrictJsonError("JSON_NODES_EXCEEDED");
|
|
59
|
+
space();
|
|
60
|
+
const c = source[at];
|
|
61
|
+
if (c === "{") {
|
|
62
|
+
at++;
|
|
63
|
+
stack.push({ kind: "object", state: "keyOrEnd", keys: new Set() });
|
|
64
|
+
} else if (c === "[") {
|
|
65
|
+
at++;
|
|
66
|
+
stack.push({ kind: "array", state: "valueOrEnd" });
|
|
67
|
+
} else if (c === '"') string();
|
|
68
|
+
else {
|
|
69
|
+
const token =
|
|
70
|
+
/^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(
|
|
71
|
+
source.slice(at),
|
|
72
|
+
);
|
|
73
|
+
if (!token) bad();
|
|
74
|
+
at += token[0].length;
|
|
75
|
+
}
|
|
76
|
+
if (stack.length > maxDepth)
|
|
77
|
+
throw new StrictJsonError("JSON_DEPTH_EXCEEDED");
|
|
78
|
+
};
|
|
79
|
+
value();
|
|
80
|
+
while (stack.length) {
|
|
81
|
+
space();
|
|
82
|
+
const frame = stack.at(-1),
|
|
83
|
+
c = source[at];
|
|
84
|
+
if (frame.kind === "object") {
|
|
85
|
+
if (frame.state === "keyOrEnd" && c === "}") {
|
|
86
|
+
at++;
|
|
87
|
+
stack.pop();
|
|
88
|
+
} else if (frame.state === "key" || frame.state === "keyOrEnd") {
|
|
89
|
+
if (c !== '"') bad();
|
|
90
|
+
const key = string();
|
|
91
|
+
if (frame.keys.has(key)) throw new StrictJsonError("DUPLICATE_KEY");
|
|
92
|
+
frame.keys.add(key);
|
|
93
|
+
frame.state = "colon";
|
|
94
|
+
} else if (frame.state === "colon") {
|
|
95
|
+
if (c !== ":") bad();
|
|
96
|
+
at++;
|
|
97
|
+
frame.state = "value";
|
|
98
|
+
} else if (frame.state === "value") {
|
|
99
|
+
frame.state = "commaOrEnd";
|
|
100
|
+
value();
|
|
101
|
+
} else if (c === "}") {
|
|
102
|
+
at++;
|
|
103
|
+
stack.pop();
|
|
104
|
+
} else if (c === ",") {
|
|
105
|
+
at++;
|
|
106
|
+
frame.state = "key";
|
|
107
|
+
} else bad();
|
|
108
|
+
} else {
|
|
109
|
+
if (frame.state === "valueOrEnd" && c === "]") {
|
|
110
|
+
at++;
|
|
111
|
+
stack.pop();
|
|
112
|
+
} else if (frame.state === "value" || frame.state === "valueOrEnd") {
|
|
113
|
+
frame.state = "commaOrEnd";
|
|
114
|
+
value();
|
|
115
|
+
} else if (c === "]") {
|
|
116
|
+
at++;
|
|
117
|
+
stack.pop();
|
|
118
|
+
} else if (c === ",") {
|
|
119
|
+
at++;
|
|
120
|
+
frame.state = "value";
|
|
121
|
+
} else bad();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
space();
|
|
125
|
+
if (at !== source.length) bad();
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(source, (_key, item) => {
|
|
128
|
+
if (typeof item === "number" && !Number.isFinite(item))
|
|
129
|
+
throw new StrictJsonError("INVALID_JSON_NUMBER");
|
|
130
|
+
return item;
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof StrictJsonError) throw error;
|
|
134
|
+
bad();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as core from '@ramex-labs/continuity/adapter';
|
|
2
|
+
import {stateOf} from '@ramex-labs/continuity/adapter';
|
|
3
|
+
import {portableAdmissionControlIsCurrent} from '@ramex-labs/continuity/adapter';
|
|
4
|
+
import {evaluatePortableReceiptPolicy} from '@ramex-labs/continuity/adapter';
|
|
5
|
+
import {captureData,record,identifier,requireCondition} from '@ramex-labs/continuity/adapter';
|
|
6
|
+
|
|
7
|
+
export const REFERENCE_TOOLS = Object.freeze([
|
|
8
|
+
{id:'ticket.create',action:'create-ticket',resource:'queue:security',fields:{title:'text'}},
|
|
9
|
+
{id:'document.access',action:'set-access',resource:'document:synthetic',fields:{level:['closed','reviewer']}},
|
|
10
|
+
{id:'document.read',action:'read-document',resource:'document:synthetic',fields:{}},
|
|
11
|
+
{id:'incident.note',action:'record-note',resource:'case:incident',fields:{note:'text'}},
|
|
12
|
+
]);
|
|
13
|
+
const equal=(a,b)=>core.canonicalEncode(a)===core.canonicalEncode(b);
|
|
14
|
+
|
|
15
|
+
/** Application-selected contracts; no model-generated schema or resolver code. */
|
|
16
|
+
export function createToolRegistry(input) {
|
|
17
|
+
const config=record(input,['serviceId','account','tools']);
|
|
18
|
+
const serviceId=identifier(config.serviceId),account=identifier(config.account);
|
|
19
|
+
requireCondition(Array.isArray(config.tools)&&config.tools.length>0&&config.tools.length<=32);
|
|
20
|
+
const definitions=config.tools.map(value=>{
|
|
21
|
+
const tool=record(value,['id','action','resource','fields'],['projection','compensates']);
|
|
22
|
+
const fields=captureData(tool.fields);
|
|
23
|
+
requireCondition(fields!==null&&typeof fields==='object'&&!Array.isArray(fields)&&Object.keys(fields).length<=16);
|
|
24
|
+
for(const [name,type] of Object.entries(fields)) {
|
|
25
|
+
identifier(name);
|
|
26
|
+
requireCondition(['text','integer','boolean','amount','identifier'].includes(type)||(Array.isArray(type)&&type.length>0&&type.length<=16&&type.every(x=>typeof x==='string'&&x.length<=128)));
|
|
27
|
+
}
|
|
28
|
+
let projection,compensates;
|
|
29
|
+
if(Object.hasOwn(tool,'projection')) {
|
|
30
|
+
projection=record(tool.projection,[],['amount','counterparty','unit']);
|
|
31
|
+
requireCondition(Object.hasOwn(projection,'amount')||Object.hasOwn(projection,'counterparty'));
|
|
32
|
+
if(Object.hasOwn(projection,'amount')) {
|
|
33
|
+
requireCondition(fields[identifier(projection.amount)]==='amount'&&Object.hasOwn(projection,'unit'));
|
|
34
|
+
identifier(projection.unit);
|
|
35
|
+
} else requireCondition(!Object.hasOwn(projection,'unit'));
|
|
36
|
+
if(Object.hasOwn(projection,'counterparty'))requireCondition(fields[identifier(projection.counterparty)]==='identifier');
|
|
37
|
+
}
|
|
38
|
+
if(Object.hasOwn(tool,'compensates')) {
|
|
39
|
+
compensates=record(tool.compensates,['tool','businessKeyField']);
|
|
40
|
+
identifier(compensates.tool);requireCondition(fields[identifier(compensates.businessKeyField)]==='identifier');
|
|
41
|
+
}
|
|
42
|
+
return {id:identifier(tool.id),action:identifier(tool.action),resource:identifier(tool.resource),fields,
|
|
43
|
+
...(projection?{projection}:{}),...(compensates?{compensates}:{})};
|
|
44
|
+
});
|
|
45
|
+
requireCondition(new Set(definitions.map(x=>x.id)).size===definitions.length);
|
|
46
|
+
requireCondition(new Set(definitions.filter(x=>x.projection?.amount).map(x=>x.projection.unit)).size<=1,'MIXED_AMOUNT_UNITS');
|
|
47
|
+
for(const tool of definitions)if(tool.compensates)requireCondition(definitions.some(x=>x.id===tool.compensates.tool&&!x.compensates),'INVALID_COMPENSATION');
|
|
48
|
+
const contractId=core.hashCanonical({version:'continuity-tool-contract/1',serviceId,account,tools:definitions});
|
|
49
|
+
const capture=(input)=>{
|
|
50
|
+
const operation=record(input,['intentId','tool','arguments','businessKey','contractId']);
|
|
51
|
+
const intentId=identifier(operation.intentId),toolId=identifier(operation.tool),businessKey=identifier(operation.businessKey);
|
|
52
|
+
requireCondition(operation.contractId===contractId,'CONTRACT_MISMATCH');
|
|
53
|
+
const tool=definitions.find(x=>x.id===toolId);requireCondition(tool,'UNKNOWN_TOOL');
|
|
54
|
+
const args=record(operation.arguments,Object.keys(tool.fields));
|
|
55
|
+
for(const [field,type] of Object.entries(tool.fields)) {
|
|
56
|
+
const value=args[field];
|
|
57
|
+
if(Array.isArray(type))requireCondition(type.includes(value));
|
|
58
|
+
else if(type==='text')requireCondition(typeof value==='string'&&value.length>0&&value.length<=1024&&!/[\u0000-\u001f\u007f]/.test(value));
|
|
59
|
+
else if(type==='amount')requireCondition(typeof value==='bigint'&&value>=0n&&value<(1n<<256n));
|
|
60
|
+
else if(type==='identifier')identifier(value);
|
|
61
|
+
else if(type==='integer')requireCondition(Number.isSafeInteger(value)&&value>=0&&!Object.is(value,-0));
|
|
62
|
+
else requireCondition(typeof value==='boolean');
|
|
63
|
+
}
|
|
64
|
+
const wire=core.immutableProtocolValue({intentId,tool:toolId,arguments:args,businessKey,contractId});
|
|
65
|
+
const termsCommitment=core.hashCanonical({version:'continuity-tool-terms/1',serviceId,account,tool:toolId,arguments:args,businessKey,contractId});
|
|
66
|
+
const quantities={...(tool.projection?.amount?{amount:args[tool.projection.amount]}:{}),
|
|
67
|
+
...(tool.projection?.counterparty?{counterparty:args[tool.projection.counterparty]}:{})};
|
|
68
|
+
return {wire,action:tool.action,resource:tool.resource,termsCommitment,quantities};
|
|
69
|
+
};
|
|
70
|
+
return Object.freeze({contractId,serviceId,account,tools:core.immutableProtocolValue(definitions),capture,
|
|
71
|
+
validateOperation(events,input,now,destinationState) {
|
|
72
|
+
requireCondition(Number.isSafeInteger(now)&&now>=0&&!Object.is(now,-0));
|
|
73
|
+
const selected=capture(input),state=stateOf(events);
|
|
74
|
+
requireCondition(now>=state.head.canonicalTime,'CLOCK_REGRESSION');
|
|
75
|
+
const admission=state.intentAdmissions.get(selected.wire.intentId),intent=state.intentDeclarations.get(selected.wire.intentId)?.data;
|
|
76
|
+
requireCondition(admission&&intent&&intent.adapterProfile.profileId===core.REMOTE_SERVICE_REPORT_ADAPTER_ID,'ADMISSION_REQUIRED');
|
|
77
|
+
requireCondition(portableAdmissionControlIsCurrent(state,intent.intentId,now),'RUNTIME_NOT_CURRENT');
|
|
78
|
+
requireCondition(!state.intentOutcomeStates.get(intent.intentId)?.terminal,'TERMINAL_INTENT');
|
|
79
|
+
requireCondition(intent.action===selected.action&&intent.resource===selected.resource&&intent.termsCommitment===selected.termsCommitment,'INTENT_MISMATCH');
|
|
80
|
+
requireCondition(Object.hasOwn(intent,'amount')===Object.hasOwn(selected.quantities,'amount')&&intent.amount===selected.quantities.amount&&
|
|
81
|
+
Object.hasOwn(intent,'counterpartyId')===Object.hasOwn(selected.quantities,'counterparty')&&intent.counterpartyId===selected.quantities.counterparty,'PROJECTION_MISMATCH');
|
|
82
|
+
const tool=definitions.find(x=>x.id===selected.wire.tool);
|
|
83
|
+
if(tool.compensates) {
|
|
84
|
+
requireCondition(destinationState?.serviceId===serviceId,'COMPENSATION_SOURCE_REQUIRED');
|
|
85
|
+
const original=destinationState.attempts.find(x=>x.operation.businessKey===selected.wire.arguments[tool.compensates.businessKeyField]);
|
|
86
|
+
requireCondition(original&&original.report.state==='APPLIED'&&original.operation.tool===tool.compensates.tool&&
|
|
87
|
+
original.operation.contractId===contractId,'COMPENSATION_SOURCE_REQUIRED');
|
|
88
|
+
}
|
|
89
|
+
const proof=state.events[admission.admissionEventPosition].data.authorizationProof;
|
|
90
|
+
requireCondition(proof.recognizedRoot.principalId===state.roles.get(intent.roleId).principalId,'WRONG_PRINCIPAL');
|
|
91
|
+
requireCondition(evaluatePortableReceiptPolicy(state,proof,now).live,'POLICY_NOT_LIVE');
|
|
92
|
+
return core.immutableProtocolValue({...selected.wire,idempotencyKey:admission.adapterIdentity.idempotencyKey,
|
|
93
|
+
submissionFingerprint:admission.adapterIdentity.submissionFingerprint});
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash, createPublicKey, sign, verify } from 'node:crypto';
|
|
2
|
+
import { captureData } from '@ramex-labs/continuity/adapter';
|
|
3
|
+
import { canonicalEncode } from '@ramex-labs/continuity/adapter';
|
|
4
|
+
import { parseStrictJson } from './strict-json.mjs';
|
|
5
|
+
|
|
6
|
+
export const MAX_WIRE_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_DATA_BYTES = 8 * 1024 * 1024;
|
|
8
|
+
const fail = code => { throw Object.assign(new Error(code), {code}); };
|
|
9
|
+
export const capture = value => captureData(value, MAX_DATA_BYTES, 300000, 96);
|
|
10
|
+
export function exact(value, keys) {
|
|
11
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
12
|
+
Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value,key))) fail('INVALID_SHAPE');
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
export function textId(value) {
|
|
16
|
+
if (typeof value !== 'string' || !value || value.length > 256 || /[\u0000-\u001f\u007f]/.test(value)) fail('INVALID_IDENTIFIER');
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
export function hashId(value) {
|
|
20
|
+
if (typeof value !== 'string' || !/^0x[0-9a-f]{64}$/.test(value)) fail('INVALID_HASH');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function integer(value) {
|
|
24
|
+
if (!Number.isSafeInteger(value) || value < 0 || Object.is(value,-0)) fail('INVALID_INTEGER');
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
export const canonicalDigest = value => '0x'+createHash('sha256').update(canonicalEncode(capture(value))).digest('hex');
|
|
28
|
+
|
|
29
|
+
// Every node is tagged, so an ordinary object resembling a bigint marker
|
|
30
|
+
// cannot become an integer during decoding. Object keys are sorted uniquely.
|
|
31
|
+
export function encodeTransport(value) {
|
|
32
|
+
const stable = capture(value);
|
|
33
|
+
const encode = node => {
|
|
34
|
+
if (node === null) return ['null'];
|
|
35
|
+
if (typeof node === 'bigint') return ['bigint',node.toString()];
|
|
36
|
+
if (Array.isArray(node)) return ['array',node.map(encode)];
|
|
37
|
+
if (typeof node === 'object') return ['object',Object.keys(node).sort().map(key => [key,encode(node[key])])];
|
|
38
|
+
if (['string','number','boolean'].includes(typeof node)) return [typeof node,node];
|
|
39
|
+
fail('INVALID_TRANSPORT_VALUE');
|
|
40
|
+
};
|
|
41
|
+
const bytes = Buffer.from(JSON.stringify(encode(stable)));
|
|
42
|
+
if (bytes.length > MAX_WIRE_BYTES) fail('WIRE_LIMIT');
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
export function decodeTransport(bytes) {
|
|
46
|
+
if (!(bytes instanceof Uint8Array)) fail('INVALID_TRANSPORT');
|
|
47
|
+
const encoded = parseStrictJson(bytes,{maxBytes:MAX_WIRE_BYTES,maxDepth:256,maxNodes:1200000});
|
|
48
|
+
let nodes=0;
|
|
49
|
+
const decode = (node,depth=0) => {
|
|
50
|
+
if (++nodes>300000 || depth>96 || !Array.isArray(node)) fail('TRANSPORT_LIMIT');
|
|
51
|
+
if (node[0]==='null' && node.length===1) return null;
|
|
52
|
+
if (node.length!==2) fail('INVALID_TRANSPORT');
|
|
53
|
+
const [tag,value]=node;
|
|
54
|
+
if (tag==='string' && typeof value==='string') return value;
|
|
55
|
+
if (tag==='boolean' && typeof value==='boolean') return value;
|
|
56
|
+
if (tag==='number' && typeof value==='number' && Number.isSafeInteger(value) && !Object.is(value,-0)) return value;
|
|
57
|
+
if (tag==='bigint' && typeof value==='string' && /^(0|[1-9][0-9]{0,77})$/.test(value)) {
|
|
58
|
+
const number=BigInt(value); if(number >= (1n<<256n)) fail('INVALID_BIGINT'); return number;
|
|
59
|
+
}
|
|
60
|
+
if (tag==='array' && Array.isArray(value)) return value.map(item=>decode(item,depth+1));
|
|
61
|
+
if (tag==='object' && Array.isArray(value)) {
|
|
62
|
+
const result=Object.create(null); let previous;
|
|
63
|
+
for (const entry of value) {
|
|
64
|
+
if (!Array.isArray(entry)||entry.length!==2||typeof entry[0]!=='string'||
|
|
65
|
+
(previous!==undefined && previous>=entry[0])) fail('INVALID_TRANSPORT_OBJECT');
|
|
66
|
+
previous=entry[0]; Object.defineProperty(result,entry[0],{value:decode(entry[1],depth+1),enumerable:true});
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
fail('INVALID_TRANSPORT');
|
|
71
|
+
};
|
|
72
|
+
return capture(decode(encoded));
|
|
73
|
+
}
|
|
74
|
+
export function publicKeyIdentity(key) {
|
|
75
|
+
const publicKey=key?.type==='public'?key:createPublicKey(key);
|
|
76
|
+
if(publicKey.asymmetricKeyType!=='ed25519') fail('UNSUPPORTED_KEY');
|
|
77
|
+
return '0x'+createHash('sha256').update(publicKey.export({type:'spki',format:'der'})).digest('hex');
|
|
78
|
+
}
|
|
79
|
+
const assertKey = (key,privateRequired=false) => {
|
|
80
|
+
if(key?.asymmetricKeyType!=='ed25519'||(privateRequired&&key.type!=='private')) fail('UNSUPPORTED_KEY');
|
|
81
|
+
};
|
|
82
|
+
export function validateOperationShape(value) {
|
|
83
|
+
exact(value,['intentId','tool','arguments','businessKey','contractId']);
|
|
84
|
+
for(const key of ['intentId','tool','businessKey','contractId']) textId(value[key]);
|
|
85
|
+
if(!value.arguments||typeof value.arguments!=='object'||Array.isArray(value.arguments)) fail('INVALID_ARGUMENTS');
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function requestBody(body) {
|
|
89
|
+
exact(body,['version','serviceId','nonce','operation','payload']);
|
|
90
|
+
if(body.version!=='continuity-cooperative-request/1'||typeof body.nonce!=='string'||! /^[0-9a-f]{64}$/.test(body.nonce)) fail('INVALID_REQUEST');
|
|
91
|
+
textId(body.serviceId);
|
|
92
|
+
switch(body.operation) {
|
|
93
|
+
case 'checkpoint': exact(body.payload,['events']); if(!Array.isArray(body.payload.events)||body.payload.events.length<1||body.payload.events.length>256)fail('INVALID_HISTORY'); break;
|
|
94
|
+
case 'prepare': exact(body.payload,['operation']); validateOperationShape(body.payload.operation); break;
|
|
95
|
+
case 'commit': case 'status': case 'cancel': exact(body.payload,['key']);hashId(body.payload.key);break;
|
|
96
|
+
default: fail('UNSUPPORTED_OPERATION');
|
|
97
|
+
}
|
|
98
|
+
return body;
|
|
99
|
+
}
|
|
100
|
+
export function validateReport(report) {
|
|
101
|
+
const fields=['state','key','fingerprint','intentId','tool','businessKey','contractId','checkpointHash'];
|
|
102
|
+
if(report?.state==='APPLIED') fields.push('effectId');
|
|
103
|
+
exact(report,fields);
|
|
104
|
+
if(!['PENDING','APPLIED','CANCELLED'].includes(report.state))fail('INVALID_REPORT');
|
|
105
|
+
for(const key of ['key','fingerprint','checkpointHash'])hashId(report[key]);
|
|
106
|
+
for(const key of ['intentId','tool','businessKey','contractId'])textId(report[key]);
|
|
107
|
+
if(report.state==='APPLIED')textId(report.effectId);
|
|
108
|
+
return report;
|
|
109
|
+
}
|
|
110
|
+
function responseBody(body) {
|
|
111
|
+
exact(body,['version','serviceId','requestDigest','sequence','result']);
|
|
112
|
+
if(body.version!=='continuity-cooperative-response/1') fail('INVALID_RESPONSE');
|
|
113
|
+
textId(body.serviceId);hashId(body.requestDigest);integer(body.sequence);
|
|
114
|
+
const result=body.result;
|
|
115
|
+
if(result?.state==='CHECKPOINTED') {
|
|
116
|
+
exact(result,['state','head']);exact(result.head,['hash','position','canonicalTime']);
|
|
117
|
+
hashId(result.head.hash);integer(result.head.position);integer(result.head.canonicalTime);
|
|
118
|
+
} else if(result?.state==='UNKNOWN') {exact(result,['state','key']);hashId(result.key);}
|
|
119
|
+
else if(result?.state==='TOO_LATE') {exact(result,['state','report']);validateReport(result.report);if(result.report.state!=='APPLIED')fail('INVALID_REPORT');}
|
|
120
|
+
else if(result?.state==='REFUSED') {
|
|
121
|
+
exact(result,['state','code']);
|
|
122
|
+
if(!['NO_CHECKPOINT','CHECKPOINT_CONFLICT','CHECKPOINT_CHANGED','OPERATION_CONFLICT','BUSINESS_KEY_CONFLICT',
|
|
123
|
+
'AUTHORIZATION_REFUSED','CLOCK_INVALID','CAPACITY_EXHAUSTED','INVALID_OPERATION','STORAGE_UNAVAILABLE'].includes(result.code))fail('INVALID_REFUSAL');
|
|
124
|
+
} else validateReport(result);
|
|
125
|
+
return body;
|
|
126
|
+
}
|
|
127
|
+
const signingBytes=(kind,body)=>Buffer.from(`continuity-cooperative-${kind}-signature/1\n${canonicalEncode(body)}`);
|
|
128
|
+
function signed(kind,body,key,validate) {
|
|
129
|
+
assertKey(key,true);const stable=capture(body);validate(stable);
|
|
130
|
+
return capture({body:stable,signature:sign(null,signingBytes(kind,stable),key).toString('hex')});
|
|
131
|
+
}
|
|
132
|
+
function verified(kind,envelope,key,validate) {
|
|
133
|
+
assertKey(key);const stable=capture(envelope);exact(stable,['body','signature']);validate(stable.body);
|
|
134
|
+
if(typeof stable.signature!=='string'||! /^[0-9a-f]{128}$/.test(stable.signature)||
|
|
135
|
+
!verify(null,signingBytes(kind,stable.body),key,Buffer.from(stable.signature,'hex')))fail('INVALID_SIGNATURE');
|
|
136
|
+
return stable.body;
|
|
137
|
+
}
|
|
138
|
+
export const signRequest=(body,key)=>signed('request',body,key,requestBody);
|
|
139
|
+
export const signResponse=(body,key)=>signed('response',body,key,responseBody);
|
|
140
|
+
export function verifyRequest(envelope,key,{serviceId}={}) {
|
|
141
|
+
const body=verified('request',envelope,key,requestBody);
|
|
142
|
+
if(serviceId!==undefined&&body.serviceId!==serviceId)fail('SERVICE_MISMATCH');
|
|
143
|
+
return body;
|
|
144
|
+
}
|
|
145
|
+
export function verifyResponse(envelope,key,{serviceId,requestDigest}={}) {
|
|
146
|
+
const body=verified('response',envelope,key,responseBody);
|
|
147
|
+
if(serviceId!==undefined&&body.serviceId!==serviceId)fail('SERVICE_MISMATCH');
|
|
148
|
+
if(requestDigest!==undefined&&body.requestDigest!==requestDigest)fail('REQUEST_MISMATCH');
|
|
149
|
+
return body;
|
|
150
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {mkdtempSync,mkdirSync,realpathSync,rmSync} from 'node:fs';
|
|
3
|
+
import {tmpdir} from 'node:os';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {generateKeyPairSync} from 'node:crypto';
|
|
6
|
+
import {generatePrivateKey,privateKeyToAccount} from 'viem/accounts';
|
|
7
|
+
import {createToolRegistry,createCooperativeDestination,createCooperativeClient,createCooperativeExecutor,createCooperativeRecovery} from '@ramex-labs/continuity-remote';
|
|
8
|
+
import {createContinuityTools} from '@ramex-labs/continuity-remote/langchain';
|
|
9
|
+
import {createLocalDomain,createLocalAttemptOwner} from '@ramex-labs/continuity-remote/local';
|
|
10
|
+
|
|
11
|
+
const dir=realpathSync(mkdtempSync(join(tmpdir(),'continuity-native-')));
|
|
12
|
+
let destination;
|
|
13
|
+
try {
|
|
14
|
+
const amount=9007199254740993n,account=privateKeyToAccount(generatePrivateKey());
|
|
15
|
+
const local={historyFile:join(dir,'history.jsonl'),domain:createLocalDomain(),owner:'operations',controller:'app',now:()=>100,
|
|
16
|
+
session:'session:bea',signHash:hash=>account.signMessage({message:{raw:hash}})};
|
|
17
|
+
const owner=createLocalAttemptOwner(local);owner.createAgent({id:'bea'});owner.createRole({id:'operator'});
|
|
18
|
+
owner.appoint({agent:'bea',role:'operator',tenure:'shift:1',number:1});
|
|
19
|
+
owner.admitRuntime({agent:'bea',session:local.session,epoch:1,key:'key:bea',address:account.address,expiresAt:1000});
|
|
20
|
+
owner.grant({id:'budget',to:'bea',actions:['allocate'],resources:['credits:example'],expiresAt:500,
|
|
21
|
+
maxAmount:amount,maxCumulativeAmount:amount,maxTransactions:1});
|
|
22
|
+
const registry=createToolRegistry({serviceId:'native-example',account:'synthetic',tools:[{
|
|
23
|
+
id:'credits.allocate',action:'allocate',resource:'credits:example',fields:{units:'amount',recipient:'identifier'},
|
|
24
|
+
projection:{amount:'units',counterparty:'recipient',unit:'credits'},
|
|
25
|
+
}]});
|
|
26
|
+
const coordinator=generateKeyPairSync('ed25519'),provider=generateKeyPairSync('ed25519');
|
|
27
|
+
mkdirSync(join(dir,'destination'),{mode:0o700});
|
|
28
|
+
destination=await createCooperativeDestination({directory:join(dir,'destination'),domain:local.domain,serviceId:registry.serviceId,
|
|
29
|
+
coordinatorPublicKey:coordinator.publicKey,servicePrivateKey:provider.privateKey,now:()=>100,validateOperation:registry.validateOperation});
|
|
30
|
+
const client=createCooperativeClient({url:destination.url,serviceId:registry.serviceId,coordinatorPrivateKey:coordinator.privateKey,servicePublicKey:provider.publicKey});
|
|
31
|
+
let commits=0;
|
|
32
|
+
const simulatedLostReply={...client,async commit(key){commits++;await client.commit(key);throw Error('Example: reply lost after durable effect');}};
|
|
33
|
+
const executor=createCooperativeExecutor({local,client:simulatedLostReply,registry,role:'operator',tenure:'shift:1'});
|
|
34
|
+
const [tool]=createContinuityTools({registry,executor,operations:[{tool:'credits.allocate',operationId:'allocation:1',businessKey:'case:1:allocation'}]});
|
|
35
|
+
const args={units:amount.toString(),recipient:'team:blue'};
|
|
36
|
+
await assert.rejects(tool.invoke({...args,operationId:'model-selected'}));
|
|
37
|
+
const first=JSON.parse(await tool.invoke(args));assert.equal(first.invocation,'OUTCOME_UNKNOWN');
|
|
38
|
+
assert.equal(destination.inspect().effects.length,1);
|
|
39
|
+
const second=JSON.parse(await tool.invoke(args));assert.equal(second.lastReportedServiceState,'APPLIED');
|
|
40
|
+
assert.equal(commits,1);assert.equal(destination.inspect().effects[0].arguments.units,amount);
|
|
41
|
+
const recovered=await createCooperativeRecovery({local,client,registry}).lookup({operationId:'allocation:1',businessKey:'case:1:allocation',tool:'credits.allocate',arguments:{units:amount,recipient:'team:blue'}});
|
|
42
|
+
assert.equal(recovered.serviceReport.result.state,'APPLIED');assert.equal(recovered.dispatchPerformed,false);
|
|
43
|
+
console.log('Installed LangChain tool: exact decimal amount, denied identity injection, lost reply, status-only recovery and one effect passed.');
|
|
44
|
+
} finally {try{await destination?.close();}finally{rmSync(dir,{recursive:true,force:true});}}
|