runners: share protocol and reviewer policy
This commit is contained in:
@@ -4,7 +4,13 @@
|
||||
"type": "module",
|
||||
"description": "Shared runner utilities for EJClaw",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
|
||||
148
runners/shared/src/agent-protocol.ts
Normal file
148
runners/shared/src/agent-protocol.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---';
|
||||
export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---';
|
||||
|
||||
export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||
|
||||
export const IPC_POLL_MS = 500;
|
||||
export const IPC_INPUT_SUBDIR = 'input';
|
||||
export const IPC_CLOSE_SENTINEL = '_close';
|
||||
|
||||
export type RunnerOutputPhase =
|
||||
| 'progress'
|
||||
| 'final'
|
||||
| 'tool-activity'
|
||||
| 'intermediate';
|
||||
|
||||
export type RunnerOutputVerdict =
|
||||
| 'done'
|
||||
| 'done_with_concerns'
|
||||
| 'blocked'
|
||||
| 'silent';
|
||||
|
||||
export type RunnerOutputVisibility = 'public' | 'silent';
|
||||
|
||||
export type RunnerStructuredOutput =
|
||||
| {
|
||||
visibility: 'public';
|
||||
text: string;
|
||||
verdict?: Exclude<RunnerOutputVerdict, 'silent'>;
|
||||
}
|
||||
| {
|
||||
visibility: 'silent';
|
||||
verdict?: 'silent';
|
||||
};
|
||||
|
||||
export interface NormalizedRunnerOutput {
|
||||
result: string | null;
|
||||
output?: RunnerStructuredOutput;
|
||||
}
|
||||
|
||||
function cloneImageTagPattern(): RegExp {
|
||||
return new RegExp(IMAGE_TAG_RE.source, IMAGE_TAG_RE.flags);
|
||||
}
|
||||
|
||||
export function writeProtocolOutput<T>(
|
||||
output: T,
|
||||
writeLine: (line: string) => void = console.log,
|
||||
): void {
|
||||
writeLine(OUTPUT_START_MARKER);
|
||||
writeLine(JSON.stringify(output));
|
||||
writeLine(OUTPUT_END_MARKER);
|
||||
}
|
||||
|
||||
export function extractImageTagPaths(text: string): {
|
||||
cleanText: string;
|
||||
imagePaths: string[];
|
||||
} {
|
||||
const imagePattern = cloneImageTagPattern();
|
||||
const imagePaths = [...text.matchAll(imagePattern)].map((match) =>
|
||||
match[1].trim(),
|
||||
);
|
||||
|
||||
return {
|
||||
cleanText: text.replace(cloneImageTagPattern(), '').trim(),
|
||||
imagePaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePublicTextOutput(
|
||||
result: string | null,
|
||||
): NormalizedRunnerOutput {
|
||||
if (typeof result !== 'string' || result.length === 0) {
|
||||
return { result };
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isVisibleVerdict(
|
||||
value: unknown,
|
||||
): value is Exclude<RunnerOutputVerdict, 'silent'> {
|
||||
return (
|
||||
value === 'done' || value === 'done_with_concerns' || value === 'blocked'
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeEjclawStructuredOutput(
|
||||
result: string | null,
|
||||
): NormalizedRunnerOutput {
|
||||
if (typeof result !== 'string' || result.length === 0) {
|
||||
return { result };
|
||||
}
|
||||
|
||||
const trimmed = result.trim();
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown };
|
||||
};
|
||||
const envelope = parsed?.ejclaw;
|
||||
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
||||
if (envelope.visibility === 'silent') {
|
||||
if (envelope.verdict !== undefined && envelope.verdict !== 'silent') {
|
||||
return normalizePublicTextOutput(result);
|
||||
}
|
||||
return {
|
||||
result: null,
|
||||
output: {
|
||||
visibility: 'silent',
|
||||
verdict:
|
||||
envelope.verdict === 'silent' ? ('silent' as const) : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
envelope.visibility === 'public' &&
|
||||
typeof envelope.text === 'string' &&
|
||||
envelope.text.length > 0
|
||||
) {
|
||||
if (
|
||||
envelope.verdict !== undefined &&
|
||||
!isVisibleVerdict(envelope.verdict)
|
||||
) {
|
||||
return normalizePublicTextOutput(result);
|
||||
}
|
||||
return {
|
||||
result: envelope.text,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: envelope.text,
|
||||
verdict: isVisibleVerdict(envelope.verdict)
|
||||
? envelope.verdict
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to plain visible text output.
|
||||
}
|
||||
|
||||
return normalizePublicTextOutput(result);
|
||||
}
|
||||
@@ -2,8 +2,44 @@ export {
|
||||
prependRoomRoleHeader,
|
||||
type RoomRoleContext,
|
||||
} from './room-role-context.js';
|
||||
export {
|
||||
extractImageTagPaths,
|
||||
IMAGE_TAG_RE,
|
||||
IPC_CLOSE_SENTINEL,
|
||||
IPC_INPUT_SUBDIR,
|
||||
IPC_POLL_MS,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizePublicTextOutput,
|
||||
OUTPUT_END_MARKER,
|
||||
OUTPUT_START_MARKER,
|
||||
writeProtocolOutput,
|
||||
type NormalizedRunnerOutput,
|
||||
type RunnerOutputPhase,
|
||||
type RunnerOutputVerdict,
|
||||
type RunnerOutputVisibility,
|
||||
type RunnerStructuredOutput,
|
||||
} from './agent-protocol.js';
|
||||
export {
|
||||
assertReadonlyWorkspaceRepoConnectivity,
|
||||
buildReviewerGitGuardEnv,
|
||||
isReviewerRuntime,
|
||||
} from './reviewer-git-guard.js';
|
||||
export {
|
||||
ARBITER_RUNTIME_ENV,
|
||||
CLAUDE_REVIEWER_READONLY_ENV,
|
||||
REVIEWER_RUNTIME_ENV,
|
||||
UNSAFE_HOST_PAIRED_MODE_ENV,
|
||||
buildClaudeReadonlySandboxSettings,
|
||||
buildPairedReadonlyRuntimeEnvOverrides,
|
||||
canUseLinuxBubblewrapReadonlySandbox,
|
||||
getClaudeReadonlySandboxMode,
|
||||
getReviewerRuntimeCapabilities,
|
||||
isArbiterRuntimeEnvEnabled,
|
||||
isClaudeReadonlyReviewerRuntime,
|
||||
isReviewerMutatingShellCommand,
|
||||
isReviewerRuntimeEnvEnabled,
|
||||
isUnsafeHostPairedModeEnabled,
|
||||
type ClaudeReadonlySandboxMode,
|
||||
type ReviewerRuntimeCapabilities,
|
||||
type RunnerAgentType,
|
||||
} from './reviewer-runtime-policy.js';
|
||||
|
||||
@@ -4,6 +4,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { RoomRoleContext } from './room-role-context.js';
|
||||
import { isUnsafeHostPairedModeEnabled } from './reviewer-runtime-policy.js';
|
||||
|
||||
const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||
'add',
|
||||
@@ -26,7 +27,7 @@ const BLOCKED_GIT_SUBCOMMANDS = new Set([
|
||||
]);
|
||||
|
||||
export function isReviewerRuntime(roomRoleContext?: RoomRoleContext): boolean {
|
||||
if (process.env.EJCLAW_UNSAFE_HOST_PAIRED_MODE === '1') {
|
||||
if (isUnsafeHostPairedModeEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return roomRoleContext?.role === 'reviewer';
|
||||
|
||||
188
runners/shared/src/reviewer-runtime-policy.ts
Normal file
188
runners/shared/src/reviewer-runtime-policy.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { RoomRoleContext } from './room-role-context.js';
|
||||
|
||||
export type RunnerAgentType = 'claude-code' | 'codex';
|
||||
export type ClaudeReadonlySandboxMode = 'strict' | 'best-effort';
|
||||
|
||||
export const REVIEWER_RUNTIME_ENV = 'EJCLAW_REVIEWER_RUNTIME';
|
||||
export const ARBITER_RUNTIME_ENV = 'EJCLAW_ARBITER_RUNTIME';
|
||||
export const UNSAFE_HOST_PAIRED_MODE_ENV = 'EJCLAW_UNSAFE_HOST_PAIRED_MODE';
|
||||
export const CLAUDE_REVIEWER_READONLY_ENV = 'EJCLAW_CLAUDE_REVIEWER_READONLY';
|
||||
|
||||
export interface ReviewerRuntimeCapabilities {
|
||||
agentType: RunnerAgentType;
|
||||
supportsShellPreflightHook: boolean;
|
||||
supportsReadonlySandboxing: boolean;
|
||||
supportsGitWriteGuard: boolean;
|
||||
supportsHardMutationBlocking: boolean;
|
||||
}
|
||||
|
||||
const REVIEWER_RUNTIME_CAPABILITIES = {
|
||||
'claude-code': {
|
||||
agentType: 'claude-code',
|
||||
supportsShellPreflightHook: true,
|
||||
supportsReadonlySandboxing: true,
|
||||
supportsGitWriteGuard: true,
|
||||
supportsHardMutationBlocking: true,
|
||||
},
|
||||
codex: {
|
||||
agentType: 'codex',
|
||||
supportsShellPreflightHook: false,
|
||||
supportsReadonlySandboxing: false,
|
||||
supportsGitWriteGuard: true,
|
||||
supportsHardMutationBlocking: false,
|
||||
},
|
||||
} satisfies Record<RunnerAgentType, ReviewerRuntimeCapabilities>;
|
||||
|
||||
const MUTATING_SHELL_PATTERNS = [
|
||||
/\bsed\s+-i\b/i,
|
||||
/\bperl\s+-i\b/i,
|
||||
/(^|[;&|])\s*(cat|echo|printf)\b[^#\n]*>>?/i,
|
||||
];
|
||||
|
||||
export function getReviewerRuntimeCapabilities(
|
||||
agentType: RunnerAgentType,
|
||||
): ReviewerRuntimeCapabilities {
|
||||
return REVIEWER_RUNTIME_CAPABILITIES[agentType];
|
||||
}
|
||||
|
||||
export function isUnsafeHostPairedModeEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env[UNSAFE_HOST_PAIRED_MODE_ENV] === '1';
|
||||
}
|
||||
|
||||
export function isReviewerRuntimeEnvEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env[REVIEWER_RUNTIME_ENV] === '1';
|
||||
}
|
||||
|
||||
export function isArbiterRuntimeEnvEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env[ARBITER_RUNTIME_ENV] === '1';
|
||||
}
|
||||
|
||||
export function isClaudeReadonlyReviewerRuntime(
|
||||
roomRoleContext?: RoomRoleContext,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return (
|
||||
isUnsafeHostPairedModeEnabled(env) &&
|
||||
env[CLAUDE_REVIEWER_READONLY_ENV] === '1' &&
|
||||
roomRoleContext?.role === 'reviewer'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildPairedReadonlyRuntimeEnvOverrides(args: {
|
||||
role: 'reviewer' | 'arbiter';
|
||||
agentType: RunnerAgentType;
|
||||
unsafeHostPairedMode: boolean;
|
||||
}): Record<string, string> {
|
||||
const { role, agentType, unsafeHostPairedMode } = args;
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
if (unsafeHostPairedMode) {
|
||||
env[UNSAFE_HOST_PAIRED_MODE_ENV] = '1';
|
||||
}
|
||||
|
||||
if (role === 'arbiter') {
|
||||
if (!unsafeHostPairedMode) {
|
||||
env[ARBITER_RUNTIME_ENV] = '1';
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
if (!unsafeHostPairedMode) {
|
||||
env[REVIEWER_RUNTIME_ENV] = '1';
|
||||
return env;
|
||||
}
|
||||
|
||||
if (getReviewerRuntimeCapabilities(agentType).supportsHardMutationBlocking) {
|
||||
env[CLAUDE_REVIEWER_READONLY_ENV] = '1';
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
let cachedLinuxBubblewrapReadonlyCapability: boolean | undefined;
|
||||
|
||||
function commandExistsForRuntime(command: string): boolean {
|
||||
try {
|
||||
execFileSync('bash', ['-lc', `command -v ${command}`], {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canUseLinuxBubblewrapReadonlySandbox(): boolean {
|
||||
if (cachedLinuxBubblewrapReadonlyCapability != null) {
|
||||
return cachedLinuxBubblewrapReadonlyCapability;
|
||||
}
|
||||
if (os.platform() !== 'linux') {
|
||||
cachedLinuxBubblewrapReadonlyCapability = false;
|
||||
return false;
|
||||
}
|
||||
if (!commandExistsForRuntime('bwrap')) {
|
||||
cachedLinuxBubblewrapReadonlyCapability = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync('bwrap', ['--ro-bind', '/', '/', '/bin/true'], {
|
||||
stdio: ['ignore', 'ignore', 'ignore'],
|
||||
});
|
||||
cachedLinuxBubblewrapReadonlyCapability = true;
|
||||
} catch {
|
||||
cachedLinuxBubblewrapReadonlyCapability = false;
|
||||
}
|
||||
|
||||
return cachedLinuxBubblewrapReadonlyCapability;
|
||||
}
|
||||
|
||||
export function getClaudeReadonlySandboxMode(
|
||||
platform: NodeJS.Platform = os.platform(),
|
||||
linuxCapabilityProbe: () => boolean = canUseLinuxBubblewrapReadonlySandbox,
|
||||
): ClaudeReadonlySandboxMode {
|
||||
if (platform === 'linux') {
|
||||
return linuxCapabilityProbe() ? 'strict' : 'best-effort';
|
||||
}
|
||||
return 'best-effort';
|
||||
}
|
||||
|
||||
export function buildClaudeReadonlySandboxSettings(
|
||||
protectedPaths: string[],
|
||||
platform: NodeJS.Platform = os.platform(),
|
||||
sandboxMode: ClaudeReadonlySandboxMode = getClaudeReadonlySandboxMode(
|
||||
platform,
|
||||
),
|
||||
) {
|
||||
const normalizedPaths = [
|
||||
...new Set(
|
||||
protectedPaths
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => path.resolve(value)),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
failIfUnavailable: sandboxMode === 'strict',
|
||||
autoAllowBashIfSandboxed: true,
|
||||
allowUnsandboxedCommands: false,
|
||||
filesystem:
|
||||
normalizedPaths.length > 0 ? { denyWrite: normalizedPaths } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function isReviewerMutatingShellCommand(command: string): boolean {
|
||||
const normalized = command.trim();
|
||||
return MUTATING_SHELL_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { RunnerAgentType } from './reviewer-runtime-policy.js';
|
||||
|
||||
export interface RoomRoleContext {
|
||||
serviceId: string;
|
||||
role: 'owner' | 'reviewer';
|
||||
role: 'owner' | 'reviewer' | 'arbiter';
|
||||
ownerServiceId: string;
|
||||
reviewerServiceId: string;
|
||||
ownerAgentType?: RunnerAgentType;
|
||||
reviewerAgentType?: RunnerAgentType | null;
|
||||
failoverOwner: boolean;
|
||||
arbiterServiceId?: string;
|
||||
arbiterAgentType?: RunnerAgentType | null;
|
||||
}
|
||||
|
||||
export function prependRoomRoleHeader(
|
||||
|
||||
78
runners/shared/test/agent-protocol.test.ts
Normal file
78
runners/shared/test/agent-protocol.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
extractImageTagPaths,
|
||||
normalizeEjclawStructuredOutput,
|
||||
normalizePublicTextOutput,
|
||||
writeProtocolOutput,
|
||||
} from '../src/agent-protocol.js';
|
||||
|
||||
describe('shared agent protocol helpers', () => {
|
||||
it('extracts image tags without leaking regex state', () => {
|
||||
expect(extractImageTagPaths('hello [Image: /tmp/a.png]')).toEqual({
|
||||
cleanText: 'hello',
|
||||
imagePaths: ['/tmp/a.png'],
|
||||
});
|
||||
expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({
|
||||
cleanText: 'second',
|
||||
imagePaths: ['/tmp/b.png'],
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes plain text runner output as public text', () => {
|
||||
expect(normalizePublicTextOutput('DONE')).toEqual({
|
||||
result: 'DONE',
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'DONE',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses silent ejclaw envelopes', () => {
|
||||
expect(
|
||||
normalizeEjclawStructuredOutput(
|
||||
JSON.stringify({
|
||||
ejclaw: { visibility: 'silent', verdict: 'silent' },
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
result: null,
|
||||
output: {
|
||||
visibility: 'silent',
|
||||
verdict: 'silent',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to visible raw text on invalid public verdicts', () => {
|
||||
const raw = JSON.stringify({
|
||||
ejclaw: {
|
||||
visibility: 'public',
|
||||
text: 'DONE',
|
||||
verdict: 'mystery',
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalizeEjclawStructuredOutput(raw)).toEqual({
|
||||
result: raw,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: raw,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('writes marker-delimited protocol output', () => {
|
||||
const lines: string[] = [];
|
||||
writeProtocolOutput({ status: 'success', result: 'ok' }, (line) => {
|
||||
lines.push(line);
|
||||
});
|
||||
|
||||
expect(lines).toEqual([
|
||||
'---EJCLAW_OUTPUT_START---',
|
||||
'{"status":"success","result":"ok"}',
|
||||
'---EJCLAW_OUTPUT_END---',
|
||||
]);
|
||||
});
|
||||
});
|
||||
77
runners/shared/test/reviewer-runtime-policy.test.ts
Normal file
77
runners/shared/test/reviewer-runtime-policy.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPairedReadonlyRuntimeEnvOverrides,
|
||||
getReviewerRuntimeCapabilities,
|
||||
} from '../src/reviewer-runtime-policy.js';
|
||||
|
||||
describe('shared reviewer runtime policy', () => {
|
||||
it('encodes claude reviewer capabilities explicitly', () => {
|
||||
expect(getReviewerRuntimeCapabilities('claude-code')).toEqual({
|
||||
agentType: 'claude-code',
|
||||
supportsShellPreflightHook: true,
|
||||
supportsReadonlySandboxing: true,
|
||||
supportsGitWriteGuard: true,
|
||||
supportsHardMutationBlocking: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('encodes codex reviewer limitations explicitly', () => {
|
||||
expect(getReviewerRuntimeCapabilities('codex')).toEqual({
|
||||
agentType: 'codex',
|
||||
supportsShellPreflightHook: false,
|
||||
supportsReadonlySandboxing: false,
|
||||
supportsGitWriteGuard: true,
|
||||
supportsHardMutationBlocking: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds reviewer runtime env for normal isolated runs', () => {
|
||||
expect(
|
||||
buildPairedReadonlyRuntimeEnvOverrides({
|
||||
role: 'reviewer',
|
||||
agentType: 'claude-code',
|
||||
unsafeHostPairedMode: false,
|
||||
}),
|
||||
).toEqual({
|
||||
EJCLAW_REVIEWER_RUNTIME: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('builds claude host reviewer env with explicit readonly flag', () => {
|
||||
expect(
|
||||
buildPairedReadonlyRuntimeEnvOverrides({
|
||||
role: 'reviewer',
|
||||
agentType: 'claude-code',
|
||||
unsafeHostPairedMode: true,
|
||||
}),
|
||||
).toEqual({
|
||||
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||
EJCLAW_CLAUDE_REVIEWER_READONLY: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves codex host reviewer mode without fake hard-block flags', () => {
|
||||
expect(
|
||||
buildPairedReadonlyRuntimeEnvOverrides({
|
||||
role: 'reviewer',
|
||||
agentType: 'codex',
|
||||
unsafeHostPairedMode: true,
|
||||
}),
|
||||
).toEqual({
|
||||
EJCLAW_UNSAFE_HOST_PAIRED_MODE: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps arbiter runtime routing unchanged', () => {
|
||||
expect(
|
||||
buildPairedReadonlyRuntimeEnvOverrides({
|
||||
role: 'arbiter',
|
||||
agentType: 'claude-code',
|
||||
unsafeHostPairedMode: false,
|
||||
}),
|
||||
).toEqual({
|
||||
EJCLAW_ARBITER_RUNTIME: '1',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user