flowviant 0.125.2 → 0.125.4
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 +4 -1
- package/bin/lib/hooks/orchestrator.mjs +12 -6
- package/bin/lib/orchestrator.mjs +6 -3
- package/bin/lib/orchestratorClient.mjs +5 -1
- package/bin/lib/orchestratorInput.mjs +47 -0
- package/bin/lib/postureTools.mjs +4 -0
- package/bin/lib/projectTools.mjs +32 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -223,5 +223,8 @@ this package version, builds, attests and attaches the binaries and manifest.
|
|
|
223
223
|
R2 publication remains opt-in through `PUBLISH_R2` and the `r2` environment.
|
|
224
224
|
For a manual upload run `sh cli/scripts/upload-binaries.sh` from the root; it
|
|
225
225
|
checks all four binaries against the manifest and publishes `latest.json` last.
|
|
226
|
-
`(cd cli && npm
|
|
226
|
+
`(cd cli && npm exec --yes --package=npm@11.17.0 -- npm publish)` retains
|
|
227
|
+
the prepublish parity gate and includes the npm shrinkwrap. The publisher is
|
|
228
|
+
pinned because [npm 12 removed shrinkwrap support](https://github.com/npm/cli/blob/latest/docs/lib/content/configuring-npm/package-lock-json.md#npm-shrinkwrapjson)
|
|
229
|
+
and omits it from tarballs; npm 12 consumers also ignore that lockfile.
|
|
227
230
|
No sibling repository or separate app deploy key is required.
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
/** Native gate; a full pool refuses only this spawn, preserving the turn. */
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { callOrchestrator } from '../orchestratorClient.mjs';
|
|
4
|
+
import { OrchestratorInputTooLarge, readOrchestratorInput } from '../orchestratorInput.mjs';
|
|
5
|
+
import { runningCompiledBinary } from '../launchCommand.mjs';
|
|
4
6
|
|
|
5
7
|
export async function main() {
|
|
6
8
|
try {
|
|
7
|
-
|
|
8
|
-
const event = JSON.parse(raw);
|
|
9
|
+
const event = await readOrchestratorInput(process.stdin);
|
|
9
10
|
const result = await callOrchestrator('hook', event);
|
|
10
11
|
const output = result.deny ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: result.deny } }
|
|
11
12
|
: result.updatedInput || result.context ? { hookSpecificOutput: { hookEventName: event.hook_event_name,
|
|
12
13
|
...(result.updatedInput ? { permissionDecision: 'allow', updatedInput: result.updatedInput } : {}),
|
|
13
14
|
...(result.context ? { additionalContext: result.context } : {}) } } : {};
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
// __hook exits as soon as main returns. A large rewritten child prompt
|
|
16
|
+
// must reach the pipe before that exit, not stop partway through JSON.
|
|
17
|
+
await new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(output)}\n`, error => error ? reject(error) : resolve()));
|
|
18
|
+
} catch (error) {
|
|
16
19
|
// Both CLIs interpret exit 2 as a tool refusal. A failed capability must
|
|
17
20
|
// never let a native spawn escape the shared admission pool.
|
|
18
|
-
process.stderr.write(
|
|
21
|
+
process.stderr.write(error instanceof OrchestratorInputTooLarge ? `${error.message}\n`
|
|
22
|
+
: 'The run’s orchestration gate is unavailable. Keep the work in the orchestrator until it reconnects.\n');
|
|
19
23
|
process.exitCode = 2;
|
|
20
24
|
}
|
|
21
25
|
}
|
|
22
|
-
|
|
26
|
+
// Bun bundles this module at the virtual entrypoint too. Only cli.mjs may
|
|
27
|
+
// dispatch it there; otherwise importing it consumes stdin before dispatch.
|
|
28
|
+
if (!runningCompiledBinary() && process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
package/bin/lib/orchestrator.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
|
10
10
|
import { runningCompiledBinary } from './launchCommand.mjs';
|
|
11
11
|
import { scrubNativeText } from './runtimeEvents.mjs';
|
|
12
12
|
import { scrub, learnExtraSecrets, forgetExtraSecrets } from './uplinkScrub.mjs';
|
|
13
|
+
import { OrchestratorInputTooLarge, readOrchestratorInput } from './orchestratorInput.mjs';
|
|
13
14
|
|
|
14
15
|
const WAIT = 'Waiting for a free slot on the machine';
|
|
15
16
|
// Codex 0.160 V2 serializes the namespace without punctuation in hook
|
|
@@ -224,11 +225,13 @@ export async function createOrchestrator({ turnId, runtime, trace, admit, reques
|
|
|
224
225
|
const server = createServer(async (req, res) => {
|
|
225
226
|
if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) { res.writeHead(403).end(); return; }
|
|
226
227
|
try {
|
|
227
|
-
|
|
228
|
-
const { action, args } = JSON.parse(raw);
|
|
228
|
+
const { action, args } = await readOrchestratorInput(req);
|
|
229
229
|
const result = await handle(action, args ?? {});
|
|
230
230
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result));
|
|
231
|
-
} catch (error) { res.writeHead(
|
|
231
|
+
} catch (error) { res.writeHead(error instanceof OrchestratorInputTooLarge ? 413 : 409,
|
|
232
|
+
{ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify({
|
|
233
|
+
error: text(error.message, 300), ...(error instanceof OrchestratorInputTooLarge ? { code: error.code } : {}),
|
|
234
|
+
})); }
|
|
232
235
|
});
|
|
233
236
|
server.requestTimeout = 5_000;
|
|
234
237
|
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { ORCHESTRATOR_TOOLS } from './orchestratorProtocol.mjs';
|
|
3
|
+
import { ORCHESTRATOR_REQUEST_BYTES, ORCHESTRATOR_INPUT_TOO_LARGE, OrchestratorInputTooLarge } from './orchestratorInput.mjs';
|
|
3
4
|
|
|
4
5
|
export async function callOrchestrator(action, args) {
|
|
5
6
|
const url = new URL(process.env.FLOWVIANT_ORCHESTRATOR_URL);
|
|
6
7
|
if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held run is available.');
|
|
8
|
+
const body = JSON.stringify({ action, args });
|
|
9
|
+
if (Buffer.byteLength(body) > ORCHESTRATOR_REQUEST_BYTES) throw new OrchestratorInputTooLarge();
|
|
7
10
|
const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_ORCHESTRATOR_CAPABILITY}`, 'Content-Type': 'application/json' },
|
|
8
|
-
body
|
|
11
|
+
body, signal: AbortSignal.timeout(15_000) });
|
|
9
12
|
const result = await response.json();
|
|
13
|
+
if (result.code === ORCHESTRATOR_INPUT_TOO_LARGE) throw new OrchestratorInputTooLarge();
|
|
10
14
|
if (!response.ok) throw new Error(result.error ?? 'This run is no longer held.');
|
|
11
15
|
return result;
|
|
12
16
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A whole-event memory guard, aligned with the authenticated API's 24 MiB
|
|
2
|
+
// request budget. It is not an allowance on a prompt or tool-result field.
|
|
3
|
+
export const ORCHESTRATOR_REQUEST_BYTES = 24 * 1024 * 1024;
|
|
4
|
+
export const ORCHESTRATOR_INPUT_TOO_LARGE = 'ORCHESTRATOR_INPUT_TOO_LARGE';
|
|
5
|
+
|
|
6
|
+
export class OrchestratorInputTooLarge extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super('The orchestration hook request exceeds the 24 MiB transport limit.');
|
|
9
|
+
this.code = ORCHESTRATOR_INPUT_TOO_LARGE;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read bounded raw bytes before decoding: pipe/HTTP chunks can split UTF-8.
|
|
14
|
+
* On refusal, drain without buffering so HTTP can still return its reason. */
|
|
15
|
+
export function readOrchestratorInput(stream) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let chunks = [], bytes = 0;
|
|
18
|
+
const cleanup = () => {
|
|
19
|
+
stream.off('data', data); stream.off('end', end);
|
|
20
|
+
stream.off('error', fail); stream.off('close', closed);
|
|
21
|
+
};
|
|
22
|
+
const fail = error => {
|
|
23
|
+
cleanup(); chunks = [];
|
|
24
|
+
// The producer can abort after seeing the refusal. That late stream
|
|
25
|
+
// error must not crash the controller while the remainder is drained.
|
|
26
|
+
const ignore = () => {};
|
|
27
|
+
stream.on('error', ignore);
|
|
28
|
+
stream.once('close', () => stream.off('error', ignore));
|
|
29
|
+
stream.resume(); reject(error);
|
|
30
|
+
};
|
|
31
|
+
const closed = () => fail(new Error('The orchestration input closed before completion.'));
|
|
32
|
+
const data = chunk => {
|
|
33
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
34
|
+
bytes += buffer.length;
|
|
35
|
+
if (bytes > ORCHESTRATOR_REQUEST_BYTES) { fail(new OrchestratorInputTooLarge()); return; }
|
|
36
|
+
chunks.push(buffer);
|
|
37
|
+
};
|
|
38
|
+
const end = () => {
|
|
39
|
+
cleanup();
|
|
40
|
+
try { resolve(JSON.parse(Buffer.concat(chunks, bytes).toString('utf8'))); }
|
|
41
|
+
catch (error) { reject(error); }
|
|
42
|
+
chunks = [];
|
|
43
|
+
};
|
|
44
|
+
stream.on('data', data); stream.once('end', end);
|
|
45
|
+
stream.once('error', fail); stream.once('close', closed);
|
|
46
|
+
});
|
|
47
|
+
}
|
package/bin/lib/postureTools.mjs
CHANGED
|
@@ -45,6 +45,10 @@ const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not whi
|
|
|
45
45
|
may be used. Use available tools needed for the person's request, including
|
|
46
46
|
native image generation for requested textures or illustrations. A listed skill
|
|
47
47
|
is not proof that its tool is installed, mounted or entitled on this account.
|
|
48
|
+
Configuration and mount names do not prove a live MCP connection. Verify access
|
|
49
|
+
with a non-mutating tool before claiming it works. If a call fails, relay its
|
|
50
|
+
actual startup, approval or connection error and a concrete corrective step;
|
|
51
|
+
an empty instance list means no connected instances, not missing tools.
|
|
48
52
|
Do not re-file the kind or require a separate image card just to use a tool.
|
|
49
53
|
If a needed tool is absent or fails, relay the measured limitation and ask for
|
|
50
54
|
the missing configuration or a decision. A separately billed CLI/API fallback
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Repo-owned tool configuration, read from the base ref for every agent turn. */
|
|
2
2
|
import { accessSync, constants, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { git, gitRaw, splitNul } from './git.mjs';
|
|
6
6
|
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
@@ -36,6 +36,19 @@ function onPath(command, env) {
|
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
const relativeCommand = (command) => !isAbsolute(command) && command.includes('/');
|
|
40
|
+
|
|
41
|
+
/** Repo launchers are not PATH commands. Measure the reviewed tree's mode,
|
|
42
|
+
* never an uncommitted executable in the checkout used to read that tree. */
|
|
43
|
+
function reviewedExecutable(root, commit, command) {
|
|
44
|
+
const path = relative(resolve(root), resolve(root, command));
|
|
45
|
+
if (!path || path === '..' || path.startsWith('../') || isAbsolute(path)) return false;
|
|
46
|
+
try {
|
|
47
|
+
return splitNul(gitRaw(['ls-tree', '-z', commit, '--', path], root, SHOW))
|
|
48
|
+
.some((entry) => entry.startsWith('100755 blob ') && entry.slice(entry.indexOf('\t') + 1) === path);
|
|
49
|
+
} catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
function referenced(value) {
|
|
40
53
|
return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
|
|
41
54
|
}
|
|
@@ -101,9 +114,14 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
|
|
|
101
114
|
for (const key of referenced(value)) vars.add(key);
|
|
102
115
|
}
|
|
103
116
|
const missing = [...vars].filter((key) => !env[key]).sort();
|
|
104
|
-
const
|
|
117
|
+
const command = typeof config.command === 'string' ? expand(config.command, env) : null;
|
|
118
|
+
const repoCommand = command != null && relativeCommand(command);
|
|
119
|
+
const commandMissing = command != null && !(repoCommand ? reviewedExecutable(root, commit, command) : onPath(command, env));
|
|
105
120
|
const unsupported = typeof config.command !== 'string' && typeof config.url !== 'string';
|
|
106
|
-
const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing
|
|
121
|
+
const reason = missing.length ? `env ${missing.join(', ')} unset` : commandMissing
|
|
122
|
+
? repoCommand ? `command ${config.command} is not an executable file in the base branch; commit the launcher with executable permission on base`
|
|
123
|
+
: `command ${config.command} not on PATH`
|
|
124
|
+
: unsupported ? 'MCP configuration unsupported' : null;
|
|
107
125
|
return { name, config, state: reason ? 'missing' : 'ready', reason };
|
|
108
126
|
});
|
|
109
127
|
// Nested instructions are normally discovered when a CLI enters that path.
|
|
@@ -119,7 +137,7 @@ export function readBaseTools(root, ref, callerEnv = process.env) {
|
|
|
119
137
|
if (instructionBytes > MAX_INSTRUCTION_BYTES) throw new Error('Base branch agent instructions exceed the turn limit');
|
|
120
138
|
instructions.push({ path, body });
|
|
121
139
|
}
|
|
122
|
-
return { tools, skills, skillFiles, instructions };
|
|
140
|
+
return { tools, skills, skillFiles, instructions, root: resolve(root) };
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
|
|
@@ -144,6 +162,11 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
144
162
|
const env = expansionEnv(callerEnv);
|
|
145
163
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
146
164
|
const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
|
|
165
|
+
// The CLI may read its config from a temporary directory, and Codex research
|
|
166
|
+
// changes cwd to an isolated copy. Keep repository launchers rooted at the
|
|
167
|
+
// agent worktree, not either of those directories or the daemon's checkout.
|
|
168
|
+
const launchRoot = worktree ?? snapshot.root;
|
|
169
|
+
const launchCommand = (command) => launchRoot && relativeCommand(command) ? resolve(launchRoot, command) : command;
|
|
147
170
|
// What the base lists and this turn cannot mount, and why — the turn is told
|
|
148
171
|
// (postureTools.mjs), so a missing server is a named fact, never a guess.
|
|
149
172
|
const mcpUnmounted = snapshot.tools
|
|
@@ -152,7 +175,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
152
175
|
const mcpServers = {};
|
|
153
176
|
for (const { name, config } of usable) {
|
|
154
177
|
if (typeof config.command === 'string') {
|
|
155
|
-
const command = expand(config.command, env);
|
|
178
|
+
const command = launchCommand(expand(config.command, env));
|
|
156
179
|
const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
|
|
157
180
|
const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
|
|
158
181
|
mcpServers[name] = { command, args, env: vars };
|
|
@@ -191,7 +214,10 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
191
214
|
mcpUnmounted,
|
|
192
215
|
/** The mount a fenced turn takes where the runner's file is ignored
|
|
193
216
|
* (`fencedMountFor`, projectToolRuntimes.mjs); null where it has none. */
|
|
194
|
-
fencedMount: fencedMountFor(runner, usable
|
|
217
|
+
fencedMount: fencedMountFor(runner, usable.map((entry) => ({ ...entry, config: {
|
|
218
|
+
...entry.config,
|
|
219
|
+
...(typeof entry.config.command === 'string' ? { command: launchCommand(entry.config.command) } : {}),
|
|
220
|
+
} })), (value) => expand(value, env), callerEnv),
|
|
195
221
|
instructions,
|
|
196
222
|
pluginDir,
|
|
197
223
|
codexHome,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.125.
|
|
3
|
+
"version": "0.125.4",
|
|
4
4
|
"description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|