@beunec/rivine 0.1.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/README.md +423 -0
- package/action.yml +165 -0
- package/dist/api/workspace.js +152 -0
- package/dist/auth/device.js +73 -0
- package/dist/auth/keychain.js +88 -0
- package/dist/auth/pkce.js +142 -0
- package/dist/auth/session.js +101 -0
- package/dist/config.js +37 -0
- package/dist/index.js +300 -0
- package/dist/mcp/config.js +73 -0
- package/dist/mcp/manager.js +154 -0
- package/dist/mcp/oauth.js +234 -0
- package/dist/mcp/relevance.js +74 -0
- package/dist/protocol/edit.js +247 -0
- package/dist/protocol/fsExecutor.js +471 -0
- package/dist/protocol/git.js +157 -0
- package/dist/protocol/permissions.js +210 -0
- package/dist/protocol/runner.js +325 -0
- package/dist/protocol/sessionKey.js +57 -0
- package/dist/protocol/shell.js +260 -0
- package/dist/protocol/snapshot.js +84 -0
- package/dist/protocol/stream.js +41 -0
- package/dist/runHeadless.js +140 -0
- package/dist/session.js +57 -0
- package/dist/ui/App.js +444 -0
- package/dist/ui/components.js +92 -0
- package/dist/ui/headless.js +130 -0
- package/dist/ui/render.js +109 -0
- package/dist/ui/slashCommands.js +57 -0
- package/dist/ui/theme.js +82 -0
- package/package.json +45 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
export const PERMISSION_MODES = ['plan', 'default', 'acceptEdits', 'bypass'];
|
|
12
|
+
export const CYCLE_MODES = ['plan', 'default', 'acceptEdits'];
|
|
13
|
+
export function isBatchable(category) {
|
|
14
|
+
return category === 'write' || category === 'mcp';
|
|
15
|
+
}
|
|
16
|
+
export function mcpServerOf(toolName) {
|
|
17
|
+
const match = /^mcp__([^_]+(?:_[^_]+)*?)__/.exec(toolName);
|
|
18
|
+
return match ? match[1] : null;
|
|
19
|
+
}
|
|
20
|
+
const READ_OPS = new Set(['ls', 'read', 'glob', 'grep', 'download']);
|
|
21
|
+
const WRITE_OPS = new Set(['write', 'edit', 'upload']);
|
|
22
|
+
const SECRET_PATTERNS = [
|
|
23
|
+
/(^|\/)\.env($|\.|\/)/i,
|
|
24
|
+
/(^|\/)\.npmrc$/i,
|
|
25
|
+
/(^|\/)\.netrc$/i,
|
|
26
|
+
/(^|\/)\.pypirc$/i,
|
|
27
|
+
/(^|\/)\.ssh(\/|$)/i,
|
|
28
|
+
/(^|\/)\.aws(\/|$)/i,
|
|
29
|
+
/(^|\/)\.gnupg(\/|$)/i,
|
|
30
|
+
/(^|\/)id_(rsa|dsa|ecdsa|ed25519)($|\.)/i,
|
|
31
|
+
/\.(pem|p12|pfx|keystore|jks)$/i,
|
|
32
|
+
/(^|\/)(credentials|secrets?)(\.[a-z0-9]+)?$/i,
|
|
33
|
+
/(^|\/)service[-_]?account.*\.json$/i,
|
|
34
|
+
];
|
|
35
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
36
|
+
'ls', 'cat', 'head', 'tail', 'wc', 'pwd', 'echo', 'file', 'stat', 'du', 'df',
|
|
37
|
+
'grep', 'rg', 'ag', 'find', 'fd', 'tree', 'which', 'whoami', 'date', 'env',
|
|
38
|
+
'diff', 'sort', 'uniq', 'cut', 'awk', 'sed', 'jq', 'basename', 'dirname',
|
|
39
|
+
]);
|
|
40
|
+
const READ_ONLY_SUBCOMMANDS = {
|
|
41
|
+
git: new Set(['status', 'log', 'diff', 'show', 'branch', 'remote', 'blame', 'ls-files', 'describe', 'rev-parse', 'config']),
|
|
42
|
+
npm: new Set(['ls', 'list', 'view', 'outdated', 'why', 'audit', 'config']),
|
|
43
|
+
pnpm: new Set(['ls', 'list', 'why', 'outdated']),
|
|
44
|
+
yarn: new Set(['list', 'why', 'info']),
|
|
45
|
+
cargo: new Set(['tree', 'metadata']),
|
|
46
|
+
docker: new Set(['ps', 'images', 'version', 'info']),
|
|
47
|
+
kubectl: new Set(['get', 'describe', 'logs', 'version']),
|
|
48
|
+
poetry: new Set(['show', 'check']),
|
|
49
|
+
};
|
|
50
|
+
const ALWAYS_ASK_COMMANDS = [
|
|
51
|
+
{ pattern: /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)/, reason: 'recursive or forced delete' },
|
|
52
|
+
{ pattern: /\bgit\s+push\b/, reason: 'pushes to a remote' },
|
|
53
|
+
{ pattern: /--force\b|--hard\b|-f\b(?=.*\bpush\b)/, reason: 'force operation' },
|
|
54
|
+
{ pattern: /\bgit\s+reset\s+--hard\b/, reason: 'discards local changes' },
|
|
55
|
+
{ pattern: /\bgit\s+clean\s+-[a-zA-Z]*[fd]/, reason: 'deletes untracked files' },
|
|
56
|
+
{ pattern: /\bsudo\b/, reason: 'runs as administrator' },
|
|
57
|
+
{ pattern: /\bnpm\s+publish\b|\byarn\s+publish\b|\bpnpm\s+publish\b/, reason: 'publishes a package' },
|
|
58
|
+
{ pattern: /\b(vercel|netlify|fly|heroku)\s+(deploy|launch)\b/, reason: 'deploys' },
|
|
59
|
+
{ pattern: /\bwrangler\s+(deploy|publish)\b/, reason: 'deploys to Cloudflare' },
|
|
60
|
+
{ pattern: /\b(sam|serverless|sls)\s+deploy\b/, reason: 'deploys to AWS' },
|
|
61
|
+
{ pattern: /\bterraform\s+(apply|destroy)\b/, reason: 'changes infrastructure' },
|
|
62
|
+
{ pattern: /\bkubectl\s+(apply|delete|rollout)\b/, reason: 'changes a cluster' },
|
|
63
|
+
{ pattern: /\bdocker\s+push\b/, reason: 'pushes an image' },
|
|
64
|
+
{ pattern: /\baws\s+\w+\s+(delete|put|create|update|remove)/, reason: 'changes AWS resources' },
|
|
65
|
+
{ pattern: /\bmkfs\b|\bdd\s+if=|\bshutdown\b|\breboot\b/, reason: 'affects the whole system' },
|
|
66
|
+
{ pattern: /\bchmod\s+(-R\s+)?777\b/, reason: 'makes files world-writable' },
|
|
67
|
+
{ pattern: /curl[^|]*\|\s*(sudo\s+)?(ba)?sh|wget[^|]*\|\s*(sudo\s+)?(ba)?sh/, reason: 'pipes a download into a shell' },
|
|
68
|
+
{ pattern: /:\(\)\s*\{.*\|.*&.*\}/, reason: 'fork bomb' },
|
|
69
|
+
];
|
|
70
|
+
const SHELL_CHAINING = /[;&|]|\$\(|`|>>|>|<\(/;
|
|
71
|
+
const AGENT_METADATA_DIR = /^\.rivine(\/|$)/;
|
|
72
|
+
export function isAgentMetadataPath(relPath) {
|
|
73
|
+
return AGENT_METADATA_DIR.test(relPath.replace(/^\.\//, ''));
|
|
74
|
+
}
|
|
75
|
+
export function isNotebookSeedCommand(command) {
|
|
76
|
+
const trimmed = command.trim();
|
|
77
|
+
if (!trimmed || SHELL_CHAINING.test(trimmed))
|
|
78
|
+
return false;
|
|
79
|
+
const tokens = trimmed.split(/\s+/);
|
|
80
|
+
const head = (tokens[0] ?? '').replace(/^.*\//, '');
|
|
81
|
+
if (head !== 'mkdir')
|
|
82
|
+
return false;
|
|
83
|
+
const args = tokens.slice(1).filter((t) => t !== '-p' && t !== '--' && !t.startsWith('-'));
|
|
84
|
+
if (args.length === 0)
|
|
85
|
+
return false;
|
|
86
|
+
return args.every((p) => {
|
|
87
|
+
const rel = p.replace(/^\/workspace\/?/, '').replace(/^\.\//, '');
|
|
88
|
+
return isAgentMetadataPath(rel);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function isSecretPath(relPath) {
|
|
92
|
+
return SECRET_PATTERNS.some((re) => re.test(relPath));
|
|
93
|
+
}
|
|
94
|
+
export function isReadOnlyCommand(command) {
|
|
95
|
+
const trimmed = command.trim();
|
|
96
|
+
if (!trimmed || SHELL_CHAINING.test(trimmed))
|
|
97
|
+
return false;
|
|
98
|
+
const tokens = trimmed.split(/\s+/);
|
|
99
|
+
const head = (tokens[0] ?? '').replace(/^.*\//, '');
|
|
100
|
+
if (READ_ONLY_COMMANDS.has(head))
|
|
101
|
+
return true;
|
|
102
|
+
const subcommands = READ_ONLY_SUBCOMMANDS[head];
|
|
103
|
+
if (subcommands) {
|
|
104
|
+
const sub = tokens.slice(1).find((t) => !t.startsWith('-'));
|
|
105
|
+
return sub !== undefined && subcommands.has(sub);
|
|
106
|
+
}
|
|
107
|
+
return /^(-v|--version|-V|--help|-h)$/.test(tokens[1] ?? '');
|
|
108
|
+
}
|
|
109
|
+
function dangerousCommand(command) {
|
|
110
|
+
for (const { pattern, reason } of ALWAYS_ASK_COMMANDS) {
|
|
111
|
+
if (pattern.test(command))
|
|
112
|
+
return reason;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
const ALLOW_OUTSIDE_PROJECT = Boolean(process.env.RIVINE_ALLOW_OUTSIDE_PROJECT);
|
|
117
|
+
export function decide(input) {
|
|
118
|
+
const { op, mode, relPath, outsideProject, command } = input;
|
|
119
|
+
if (outsideProject) {
|
|
120
|
+
if (mode === 'bypass') {
|
|
121
|
+
return ALLOW_OUTSIDE_PROJECT
|
|
122
|
+
? { verdict: 'allow' }
|
|
123
|
+
: {
|
|
124
|
+
verdict: 'deny',
|
|
125
|
+
reason: `${relPath ?? 'this path'} is outside the project directory; bypass mode ` +
|
|
126
|
+
'does not leave the project (set RIVINE_ALLOW_OUTSIDE_PROJECT=1 if you mean it)',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
verdict: 'ask',
|
|
131
|
+
category: 'outside',
|
|
132
|
+
reason: `${relPath ?? 'this path'} is outside the project directory`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (mode === 'bypass')
|
|
136
|
+
return { verdict: 'allow' };
|
|
137
|
+
if (relPath && isSecretPath(relPath)) {
|
|
138
|
+
return {
|
|
139
|
+
verdict: 'ask',
|
|
140
|
+
category: 'secret',
|
|
141
|
+
reason: READ_OPS.has(op)
|
|
142
|
+
? `${relPath} looks like it holds credentials — reading it would send them to the model`
|
|
143
|
+
: `${relPath} looks like it holds credentials`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (op === 'mcp_call') {
|
|
147
|
+
if (mode === 'plan') {
|
|
148
|
+
return {
|
|
149
|
+
verdict: 'deny',
|
|
150
|
+
reason: 'plan mode does not call external tools — press Shift+Tab to change mode',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return { verdict: 'ask', category: 'mcp', reason: 'calls an external tool on your machine' };
|
|
154
|
+
}
|
|
155
|
+
if (op === 'execute') {
|
|
156
|
+
const cmd = command ?? '';
|
|
157
|
+
const danger = dangerousCommand(cmd);
|
|
158
|
+
if (danger)
|
|
159
|
+
return { verdict: 'ask', category: 'destructive', reason: danger };
|
|
160
|
+
if (isNotebookSeedCommand(cmd) && mode !== 'plan') {
|
|
161
|
+
return { verdict: 'allow' };
|
|
162
|
+
}
|
|
163
|
+
if (mode === 'acceptEdits' && isReadOnlyCommand(cmd)) {
|
|
164
|
+
return { verdict: 'allow' };
|
|
165
|
+
}
|
|
166
|
+
if (mode === 'plan') {
|
|
167
|
+
return isReadOnlyCommand(cmd)
|
|
168
|
+
? { verdict: 'allow' }
|
|
169
|
+
: {
|
|
170
|
+
verdict: 'deny',
|
|
171
|
+
reason: 'plan mode runs read-only commands only — press Shift+Tab to change mode',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { verdict: 'ask', category: 'command', reason: 'runs a command on your machine' };
|
|
175
|
+
}
|
|
176
|
+
if (READ_OPS.has(op))
|
|
177
|
+
return { verdict: 'allow' };
|
|
178
|
+
if (WRITE_OPS.has(op)) {
|
|
179
|
+
if (mode === 'plan') {
|
|
180
|
+
return {
|
|
181
|
+
verdict: 'deny',
|
|
182
|
+
reason: 'plan mode does not modify files — press Shift+Tab to change mode',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
if (relPath && isAgentMetadataPath(relPath))
|
|
186
|
+
return { verdict: 'allow' };
|
|
187
|
+
if (mode === 'acceptEdits')
|
|
188
|
+
return { verdict: 'allow' };
|
|
189
|
+
return { verdict: 'ask', category: 'write', reason: `writes ${relPath ?? 'a file'}` };
|
|
190
|
+
}
|
|
191
|
+
return { verdict: 'ask', category: 'command', reason: 'unrecognised operation' };
|
|
192
|
+
}
|
|
193
|
+
export function nextMode(current) {
|
|
194
|
+
const index = CYCLE_MODES.indexOf(current);
|
|
195
|
+
if (index === -1)
|
|
196
|
+
return 'default';
|
|
197
|
+
return CYCLE_MODES[(index + 1) % CYCLE_MODES.length];
|
|
198
|
+
}
|
|
199
|
+
export function describeMode(mode) {
|
|
200
|
+
switch (mode) {
|
|
201
|
+
case 'plan':
|
|
202
|
+
return 'plan · reads only';
|
|
203
|
+
case 'default':
|
|
204
|
+
return 'default · asks before changes';
|
|
205
|
+
case 'acceptEdits':
|
|
206
|
+
return 'accept edits · writes apply, commands ask';
|
|
207
|
+
case 'bypass':
|
|
208
|
+
return 'bypass · no prompts';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
import { closeSession, openTurn, postFsResult, resumeTurn } from '../api/workspace.js';
|
|
12
|
+
import { apiUrl } from '../config.js';
|
|
13
|
+
import { accessToken } from '../auth/session.js';
|
|
14
|
+
import { classify, execute } from './fsExecutor.js';
|
|
15
|
+
import { isSealedEnvelope, SessionKey } from './sessionKey.js';
|
|
16
|
+
import { selectRelevantTools } from '../mcp/relevance.js';
|
|
17
|
+
import { readTurnStream } from './stream.js';
|
|
18
|
+
const PENDING_POLL_MS = 1200;
|
|
19
|
+
export class TurnRunner {
|
|
20
|
+
ctx;
|
|
21
|
+
cb;
|
|
22
|
+
mcp;
|
|
23
|
+
seen = new Set();
|
|
24
|
+
static SEEN_LIMIT = 5000;
|
|
25
|
+
approvalChain = Promise.resolve();
|
|
26
|
+
inFlight = new Set();
|
|
27
|
+
stopped = false;
|
|
28
|
+
pollTimer = null;
|
|
29
|
+
constructor(ctx, cb, mcp) {
|
|
30
|
+
this.ctx = ctx;
|
|
31
|
+
this.cb = cb;
|
|
32
|
+
this.mcp = mcp;
|
|
33
|
+
}
|
|
34
|
+
shell = null;
|
|
35
|
+
key = SessionKey.generate();
|
|
36
|
+
setMode(mode) {
|
|
37
|
+
this.ctx.mode = mode;
|
|
38
|
+
}
|
|
39
|
+
writeToShell(data) {
|
|
40
|
+
if (!this.shell)
|
|
41
|
+
return false;
|
|
42
|
+
this.shell.write(data);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
get interactiveShell() {
|
|
46
|
+
return Boolean(this.shell);
|
|
47
|
+
}
|
|
48
|
+
resizeShell(cols, rows) {
|
|
49
|
+
this.shell?.resize(cols, rows);
|
|
50
|
+
}
|
|
51
|
+
killShell() {
|
|
52
|
+
if (!this.shell)
|
|
53
|
+
return false;
|
|
54
|
+
this.shell.kill();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
executeHooks(approvedOutside = false) {
|
|
58
|
+
return {
|
|
59
|
+
onOutput: (text) => this.cb.onOutput?.(text),
|
|
60
|
+
onShell: (handle) => {
|
|
61
|
+
this.shell = handle;
|
|
62
|
+
this.cb.onShell?.(handle);
|
|
63
|
+
},
|
|
64
|
+
approvedOutside,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
setMcpTools(tools) {
|
|
68
|
+
this.ctx.mcpTools = tools;
|
|
69
|
+
}
|
|
70
|
+
async run(message, snapshot) {
|
|
71
|
+
this.stopped = false;
|
|
72
|
+
this.startPolling();
|
|
73
|
+
const selection = selectRelevantTools(message, this.ctx.mcpTools ?? []);
|
|
74
|
+
if (selection.servers.length) {
|
|
75
|
+
this.cb.onNotice(`MCP: using ${selection.servers.join(', ')}` +
|
|
76
|
+
(selection.skipped.length ? ` (not needed: ${selection.skipped.join(', ')})` : ''));
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const response = await openTurn({
|
|
80
|
+
workspaceId: this.ctx.workspaceId,
|
|
81
|
+
chatSpaceId: this.ctx.chatSpaceId,
|
|
82
|
+
sessionId: this.ctx.sessionId,
|
|
83
|
+
message,
|
|
84
|
+
permissionMode: this.ctx.mode,
|
|
85
|
+
cwdSnapshot: snapshot,
|
|
86
|
+
mcpTools: selection.tools.map(({ name, description, input_schema }) => ({
|
|
87
|
+
name,
|
|
88
|
+
description,
|
|
89
|
+
input_schema,
|
|
90
|
+
})),
|
|
91
|
+
sessionKey: this.key.toBase64(),
|
|
92
|
+
});
|
|
93
|
+
return await this.consume(response);
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
this.stopPolling();
|
|
97
|
+
await Promise.allSettled([...this.inFlight]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async consume(response) {
|
|
101
|
+
let answer = '';
|
|
102
|
+
let pendingHitl = null;
|
|
103
|
+
for await (const event of readTurnStream(response)) {
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case 'token':
|
|
106
|
+
answer += event.content ?? '';
|
|
107
|
+
this.cb.onToken(event.content ?? '');
|
|
108
|
+
break;
|
|
109
|
+
case 'stage':
|
|
110
|
+
this.cb.onStage(String(event.label ?? ''));
|
|
111
|
+
break;
|
|
112
|
+
case 'tool_call_start': {
|
|
113
|
+
const e = event;
|
|
114
|
+
const detail = e.args?.command ?? e.args?.filename ?? '';
|
|
115
|
+
this.cb.onTool(String(e.name ?? 'tool'), String(detail));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case 'code_file': {
|
|
119
|
+
const e = event;
|
|
120
|
+
this.cb.onFileTouched(String(e.path ?? ''), e.status);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'fs_request':
|
|
124
|
+
this.dispatch(event);
|
|
125
|
+
break;
|
|
126
|
+
case 'approval_request': {
|
|
127
|
+
const e = event;
|
|
128
|
+
pendingHitl = {
|
|
129
|
+
message: String(e.message ?? 'Approval required.'),
|
|
130
|
+
tool: e.tool ? String(e.tool) : undefined,
|
|
131
|
+
options: extractOptions(e),
|
|
132
|
+
multi: Boolean(e.ui_schema?.multi ?? e.args?.multi),
|
|
133
|
+
threadId: String(e.thread_id ?? `${this.ctx.workspaceId}_${this.ctx.chatSpaceId}`),
|
|
134
|
+
};
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'final_answer':
|
|
138
|
+
answer = String(event.content ?? answer);
|
|
139
|
+
break;
|
|
140
|
+
case 'error': {
|
|
141
|
+
const e = event;
|
|
142
|
+
return { answer, cancelled: Boolean(e.cancelled), error: String(e.content ?? 'Unknown error') };
|
|
143
|
+
}
|
|
144
|
+
case 'done':
|
|
145
|
+
break;
|
|
146
|
+
default:
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (pendingHitl) {
|
|
151
|
+
return { answer: await this.settleHitl(pendingHitl), cancelled: false };
|
|
152
|
+
}
|
|
153
|
+
return { answer, cancelled: false };
|
|
154
|
+
}
|
|
155
|
+
async settleHitl(first) {
|
|
156
|
+
let current = first;
|
|
157
|
+
let answer = '';
|
|
158
|
+
for (let round = 0; current && round < 25; round += 1) {
|
|
159
|
+
const decision = await this.enqueuePrompt(() => this.cb.requestDecision(current));
|
|
160
|
+
const body = (await resumeTurn({
|
|
161
|
+
workspaceId: this.ctx.workspaceId,
|
|
162
|
+
chatSpaceId: this.ctx.chatSpaceId,
|
|
163
|
+
threadId: current.threadId,
|
|
164
|
+
sessionId: this.ctx.sessionId,
|
|
165
|
+
sessionKey: this.key.toBase64(),
|
|
166
|
+
approved: decision.approved,
|
|
167
|
+
feedback: decision.feedback,
|
|
168
|
+
modifiedArgs: decision.modifiedArgs,
|
|
169
|
+
}));
|
|
170
|
+
answer = String(body.response ?? answer);
|
|
171
|
+
const meta = body.context_metadata ?? {};
|
|
172
|
+
current = meta.paused
|
|
173
|
+
? {
|
|
174
|
+
message: String(meta.hitl_prompt ?? body.response ?? 'Approval required.'),
|
|
175
|
+
tool: meta.hitl_tool ? String(meta.hitl_tool) : undefined,
|
|
176
|
+
options: extractOptions({ ui_schema: meta.hitl_ui_schema, args: meta.hitl_args }),
|
|
177
|
+
multi: Boolean(meta.hitl_ui_schema?.multi),
|
|
178
|
+
threadId: String(meta.thread_id ?? current.threadId),
|
|
179
|
+
}
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
return answer;
|
|
183
|
+
}
|
|
184
|
+
dispatch(request) {
|
|
185
|
+
if (this.stopped || this.seen.has(request.request_id))
|
|
186
|
+
return;
|
|
187
|
+
if (this.seen.size >= TurnRunner.SEEN_LIMIT) {
|
|
188
|
+
const keep = [...this.seen].slice(-TurnRunner.SEEN_LIMIT / 2);
|
|
189
|
+
this.seen.clear();
|
|
190
|
+
for (const id of keep)
|
|
191
|
+
this.seen.add(id);
|
|
192
|
+
}
|
|
193
|
+
this.seen.add(request.request_id);
|
|
194
|
+
const task = (async () => {
|
|
195
|
+
let outcome;
|
|
196
|
+
try {
|
|
197
|
+
if (request.op === 'mcp_call') {
|
|
198
|
+
const { approval, refusal } = await classify(this.ctx.root, request, this.ctx.mode);
|
|
199
|
+
if (refusal) {
|
|
200
|
+
outcome = refusal;
|
|
201
|
+
}
|
|
202
|
+
else if (approval && !(await this.enqueuePrompt(() => this.cb.requestApproval(approval), false))) {
|
|
203
|
+
outcome = { ok: false, error: 'The developer declined this tool call.' };
|
|
204
|
+
}
|
|
205
|
+
else if (!this.mcp) {
|
|
206
|
+
outcome = { ok: false, error: 'No MCP servers are connected in this session.' };
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const result = await this.mcp.call(String(request.args.tool ?? ''), (request.args.args ?? {}));
|
|
210
|
+
outcome = { ok: true, data: { result } };
|
|
211
|
+
}
|
|
212
|
+
await postFsResult(this.ctx.sessionId, request.request_id, outcome, this.key);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (request.op === 'ask') {
|
|
216
|
+
const question = {
|
|
217
|
+
prompt: String(request.args.prompt ?? ''),
|
|
218
|
+
options: request.args.options?.filter(Boolean),
|
|
219
|
+
multi: Boolean(request.args.multi),
|
|
220
|
+
};
|
|
221
|
+
const answer = await this.enqueuePrompt(() => this.cb.askQuestion(question), '');
|
|
222
|
+
outcome = { ok: true, data: { answer } };
|
|
223
|
+
await postFsResult(this.ctx.sessionId, request.request_id, outcome, this.key);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const { approval, refusal } = await classify(this.ctx.root, request, this.ctx.mode);
|
|
227
|
+
if (refusal) {
|
|
228
|
+
outcome = refusal;
|
|
229
|
+
}
|
|
230
|
+
else if (approval) {
|
|
231
|
+
const allowed = await this.enqueuePrompt(() => this.cb.requestApproval(approval), false);
|
|
232
|
+
outcome = allowed
|
|
233
|
+
? await execute(this.ctx.root, request, this.executeHooks(approval.category === 'outside'))
|
|
234
|
+
: { ok: false, error: 'The developer declined this operation.' };
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
outcome = await execute(this.ctx.root, request, this.executeHooks());
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
outcome = { ok: false, error: err.message };
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
await postFsResult(this.ctx.sessionId, request.request_id, outcome, this.key);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
this.cb.onNotice(`Could not report a file operation: ${err.message}`);
|
|
248
|
+
}
|
|
249
|
+
})();
|
|
250
|
+
this.inFlight.add(task);
|
|
251
|
+
void task.finally(() => this.inFlight.delete(task));
|
|
252
|
+
}
|
|
253
|
+
enqueuePrompt(ask, fallback) {
|
|
254
|
+
let release;
|
|
255
|
+
const answered = new Promise((r) => {
|
|
256
|
+
release = r;
|
|
257
|
+
});
|
|
258
|
+
this.approvalChain = this.approvalChain.then(async () => {
|
|
259
|
+
if (this.stopped) {
|
|
260
|
+
release(fallback);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
release(await ask().catch(() => fallback));
|
|
264
|
+
});
|
|
265
|
+
return answered;
|
|
266
|
+
}
|
|
267
|
+
startPolling() {
|
|
268
|
+
if (this.pollTimer)
|
|
269
|
+
return;
|
|
270
|
+
const tick = async () => {
|
|
271
|
+
if (this.stopped)
|
|
272
|
+
return;
|
|
273
|
+
try {
|
|
274
|
+
const res = await fetch(`${apiUrl()}/api/cli/sessions/${this.ctx.sessionId}/pending`, { headers: { authorization: `Bearer ${await accessToken()}` } });
|
|
275
|
+
if (res.ok) {
|
|
276
|
+
const body = (await res.json());
|
|
277
|
+
for (const request of body.requests ?? []) {
|
|
278
|
+
const opened = this.openPending(request);
|
|
279
|
+
if (opened)
|
|
280
|
+
this.dispatch(opened);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
this.pollTimer = setInterval(() => void tick(), PENDING_POLL_MS);
|
|
288
|
+
this.pollTimer.unref?.();
|
|
289
|
+
}
|
|
290
|
+
openPending(row) {
|
|
291
|
+
if (!row.sealed)
|
|
292
|
+
return row;
|
|
293
|
+
if (!isSealedEnvelope(row.sealed))
|
|
294
|
+
return null;
|
|
295
|
+
try {
|
|
296
|
+
return { ...row, args: this.key.open(row.sealed) };
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
this.cb.onNotice('Ignored a file operation that failed its integrity check.');
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
stopPolling() {
|
|
304
|
+
if (this.pollTimer) {
|
|
305
|
+
clearInterval(this.pollTimer);
|
|
306
|
+
this.pollTimer = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async shutdown() {
|
|
310
|
+
this.stopped = true;
|
|
311
|
+
this.stopPolling();
|
|
312
|
+
await closeSession(this.ctx.sessionId);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function extractOptions(event) {
|
|
316
|
+
const raw = event?.ui_schema?.options ??
|
|
317
|
+
event?.ui_schema?.choices ??
|
|
318
|
+
event?.args?.options ??
|
|
319
|
+
event?.options;
|
|
320
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
321
|
+
return undefined;
|
|
322
|
+
return raw
|
|
323
|
+
.map((item) => (typeof item === 'string' ? item : (item?.label ?? item?.value ?? '')))
|
|
324
|
+
.filter((label) => label.length > 0);
|
|
325
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Rivine Code CLI
|
|
3
|
+
* Licensed and owned by Beunec Technologies, Inc.
|
|
4
|
+
*
|
|
5
|
+
* https://aselius.beunec.com
|
|
6
|
+
*
|
|
7
|
+
* This software is proprietary. Use is governed by the licence agreement
|
|
8
|
+
* accompanying it; it is not open source and is not licensed for
|
|
9
|
+
* redistribution or derivative works.
|
|
10
|
+
*/
|
|
11
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
12
|
+
const VERSION = 2;
|
|
13
|
+
const KEY_BYTES = 32;
|
|
14
|
+
const NONCE_BYTES = 12;
|
|
15
|
+
const TAG_BYTES = 16;
|
|
16
|
+
export function isSealedEnvelope(value) {
|
|
17
|
+
if (!value || typeof value !== 'object')
|
|
18
|
+
return false;
|
|
19
|
+
const e = value;
|
|
20
|
+
return e.v === VERSION && typeof e.n === 'string' && typeof e.ct === 'string';
|
|
21
|
+
}
|
|
22
|
+
export class SessionKey {
|
|
23
|
+
key;
|
|
24
|
+
constructor(key) {
|
|
25
|
+
this.key = key;
|
|
26
|
+
}
|
|
27
|
+
static generate() {
|
|
28
|
+
return new SessionKey(randomBytes(KEY_BYTES));
|
|
29
|
+
}
|
|
30
|
+
toBase64() {
|
|
31
|
+
return this.key.toString('base64');
|
|
32
|
+
}
|
|
33
|
+
seal(value) {
|
|
34
|
+
const nonce = randomBytes(NONCE_BYTES);
|
|
35
|
+
const cipher = createCipheriv('aes-256-gcm', this.key, nonce);
|
|
36
|
+
const body = Buffer.concat([
|
|
37
|
+
cipher.update(Buffer.from(JSON.stringify(value), 'utf8')),
|
|
38
|
+
cipher.final(),
|
|
39
|
+
]);
|
|
40
|
+
return {
|
|
41
|
+
v: VERSION,
|
|
42
|
+
n: nonce.toString('base64'),
|
|
43
|
+
ct: Buffer.concat([body, cipher.getAuthTag()]).toString('base64'),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
open(envelope) {
|
|
47
|
+
const raw = Buffer.from(envelope.ct, 'base64');
|
|
48
|
+
if (raw.length < TAG_BYTES)
|
|
49
|
+
throw new Error('sealed payload is too short to be valid');
|
|
50
|
+
const body = raw.subarray(0, raw.length - TAG_BYTES);
|
|
51
|
+
const tag = raw.subarray(raw.length - TAG_BYTES);
|
|
52
|
+
const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(envelope.n, 'base64'));
|
|
53
|
+
decipher.setAuthTag(tag);
|
|
54
|
+
const plain = Buffer.concat([decipher.update(body), decipher.final()]);
|
|
55
|
+
return JSON.parse(plain.toString('utf8'));
|
|
56
|
+
}
|
|
57
|
+
}
|