@commonlyai/cli 0.1.173 → 0.1.175
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/node_modules/@commonlyai/seats/README.md +11 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +206 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +38 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +261 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +635 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +45 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +362 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +164 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +118 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/package.json +6 -1
- package/src/commands/agent.js +21 -3
- package/src/lib/adapters/claude.js +8 -3
- package/src/lib/adapters/codex.js +5 -2
- package/src/lib/adapters/hermes.js +13 -194
- package/src/lib/adapters/seat-model.js +5 -12
- package/src/lib/adapters/stub.js +1 -24
- package/src/lib/runtime-observation.js +3 -27
- package/src/lib/seat-stop.js +96 -0
- package/src/lib/upstream-refusal.js +1 -354
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the env and HOME one run executes in. The run starts from nothing the
|
|
3
|
+
* user's login can be found through: a short allowlist of locale/terminal
|
|
4
|
+
* variables, a fresh HOME whose XDG dirs sit inside it, and only the
|
|
5
|
+
* credentials its grant names.
|
|
6
|
+
*
|
|
7
|
+
* What this is NOT: a filesystem sandbox. A process that already knows an
|
|
8
|
+
* absolute path (/Users/<you>/.claude) can still open it, and macOS keychain
|
|
9
|
+
* items are reached through the user's security session, not through HOME.
|
|
10
|
+
* This removes the paths a CLI discovers its login by; an OS sandbox
|
|
11
|
+
* (Seatbelt, bwrap) is what stops a process that goes looking.
|
|
12
|
+
*/
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { mkdtemp, mkdir, chmod, readFile, rm } from 'node:fs/promises';
|
|
15
|
+
import { homedir, tmpdir } from 'node:os';
|
|
16
|
+
import { delimiter, join, resolve, sep } from 'node:path';
|
|
17
|
+
import { traceOf } from '../trace/events.js';
|
|
18
|
+
import { runFailure } from '../failures.js';
|
|
19
|
+
import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js';
|
|
20
|
+
import { validateGrant } from './grant.js';
|
|
21
|
+
|
|
22
|
+
// Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust
|
|
23
|
+
// only. USER/LOGNAME are left out on purpose; they name the account whose
|
|
24
|
+
// login this boundary exists to hide.
|
|
25
|
+
export const BASE_ENV_ALLOWLIST = /^(?:PATH|LANG|LC_[A-Z0-9_]+|TERM|TZ|SSL_CERT_FILE|SSL_CERT_DIR|NO_COLOR|CI)$/;
|
|
26
|
+
|
|
27
|
+
const isInside = (path, dir) => {
|
|
28
|
+
const a = resolve(path);
|
|
29
|
+
const b = resolve(dir);
|
|
30
|
+
return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* PATH entries under the real home both name it and often hold shims that
|
|
35
|
+
* read the user's login (~/.local/bin/claude). Adapters run their CLI by
|
|
36
|
+
* absolute path, so the run loses nothing it needs.
|
|
37
|
+
*/
|
|
38
|
+
const scrubPath = (value, realHomes) => value.split(delimiter)
|
|
39
|
+
.filter((entry) => entry && !realHomes.some((home) => isInside(entry, home)))
|
|
40
|
+
.join(delimiter);
|
|
41
|
+
|
|
42
|
+
const pickBaseEnv = (baseEnv, realHomes) => {
|
|
43
|
+
const env = {};
|
|
44
|
+
for (const [name, value] of Object.entries(baseEnv)) {
|
|
45
|
+
if (typeof value === 'string' && BASE_ENV_ALLOWLIST.test(name)) env[name] = value;
|
|
46
|
+
}
|
|
47
|
+
if (env.PATH !== undefined) env.PATH = scrubPath(env.PATH, realHomes);
|
|
48
|
+
return env;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const makeHome = async (root) => {
|
|
52
|
+
const home = await mkdtemp(join(root || tmpdir(), 'seat-home-'));
|
|
53
|
+
// Conventional layout, so a CLI that ignores XDG_* and defaults to
|
|
54
|
+
// ~/.config still lands inside the throwaway HOME.
|
|
55
|
+
const dirs = {
|
|
56
|
+
XDG_CONFIG_HOME: join(home, '.config'),
|
|
57
|
+
XDG_DATA_HOME: join(home, '.local', 'share'),
|
|
58
|
+
XDG_STATE_HOME: join(home, '.local', 'state'),
|
|
59
|
+
XDG_CACHE_HOME: join(home, '.cache'),
|
|
60
|
+
TMPDIR: join(home, 'tmp'),
|
|
61
|
+
};
|
|
62
|
+
await chmod(home, 0o700);
|
|
63
|
+
for (const dir of Object.values(dirs)) await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
64
|
+
// mkdir's mode is masked by umask; set it explicitly on every level made.
|
|
65
|
+
for (const dir of [join(home, '.local'), ...Object.values(dirs)]) await chmod(dir, 0o700);
|
|
66
|
+
return { home, dirs };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A value that cannot ride an env var is the same as no value. */
|
|
70
|
+
const usable = (value) => typeof value === 'string' && value.length > 0 && !value.includes('\0');
|
|
71
|
+
|
|
72
|
+
const resolveOne = async (spec, { baseEnv, resolveSecret }) => {
|
|
73
|
+
if (spec.from === 'env') return baseEnv[spec.ref || spec.name];
|
|
74
|
+
if (spec.from === 'file') return (await readFile(spec.ref, 'utf8')).replace(/\r?\n$/, '');
|
|
75
|
+
if (typeof resolveSecret !== 'function') return undefined;
|
|
76
|
+
return resolveSecret(spec.name, spec);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The grant.issued entry for grantMeta. Its scope is the room grant this run
|
|
81
|
+
* acts under (exact tool names, a connection id), as the host knows it; the
|
|
82
|
+
* run grant's own credential names and patterns show up in credential.* and
|
|
83
|
+
* in each permission.decided rule. `taskId` + `podId` bind the grant to that
|
|
84
|
+
* board task, ending when it is done unless `endsOn` says otherwise.
|
|
85
|
+
*/
|
|
86
|
+
const grantIssued = (meta) => {
|
|
87
|
+
const {
|
|
88
|
+
grantId = randomUUID(), parentGrantId, scope = {}, target, issuedBy, expiresAt, taskId, podId, endsOn = 'task.done',
|
|
89
|
+
} = meta;
|
|
90
|
+
return {
|
|
91
|
+
grantId,
|
|
92
|
+
...(parentGrantId === undefined ? {} : { parentGrantId }),
|
|
93
|
+
scope: { tools: [], audience: [], ...scope },
|
|
94
|
+
...(target ? { target } : {}),
|
|
95
|
+
...(issuedBy ? { issuedBy } : {}),
|
|
96
|
+
...(expiresAt ? { expiresAt: expiresAt instanceof Date ? expiresAt.toISOString() : expiresAt } : {}),
|
|
97
|
+
...(taskId ? { binding: { taskId, podId, endsOn } } : {}),
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? })
|
|
103
|
+
* → { env, home, cleanup, granted: [names], grantId? }
|
|
104
|
+
*
|
|
105
|
+
* `require` lists credential names the adapter cannot run without; one the
|
|
106
|
+
* grant does not name fails the run as credential_denied before any spawn.
|
|
107
|
+
* A named credential that cannot be resolved, or that resolves to fewer than
|
|
108
|
+
* MIN_CREDENTIAL_LENGTH characters, fails it the same way: a run that
|
|
109
|
+
* silently lacks its key fails later and less legibly, and a value that short
|
|
110
|
+
* would slip past the refusal-text reader's redaction.
|
|
111
|
+
*
|
|
112
|
+
* Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta,
|
|
113
|
+
* first), then credential.granted {names} or credential.denied {name, reason}.
|
|
114
|
+
* Values never enter an event or a message, and a hook that offers
|
|
115
|
+
* redact(values) is handed the resolved values so the record can scrub them
|
|
116
|
+
* out of agent text too.
|
|
117
|
+
*/
|
|
118
|
+
export const prepareRunEnv = async ({
|
|
119
|
+
grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta,
|
|
120
|
+
} = {}) => {
|
|
121
|
+
const valid = validateGrant(grant);
|
|
122
|
+
const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx);
|
|
123
|
+
const issued = grantMeta ? grantIssued(grantMeta) : null;
|
|
124
|
+
if (issued) hook.emit('grant.issued', issued);
|
|
125
|
+
const realHomes = [...new Set([baseEnv.HOME, homedir()].filter(Boolean))];
|
|
126
|
+
const DENIED = {
|
|
127
|
+
not_granted: 'is not in the run grant',
|
|
128
|
+
unresolved: 'could not be resolved',
|
|
129
|
+
too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`,
|
|
130
|
+
};
|
|
131
|
+
const deny = (name, reason, values) => {
|
|
132
|
+
hook.emit('credential.denied', { name, reason });
|
|
133
|
+
return runFailure('credential_denied', `${name} ${DENIED[reason]}`, { credentials: values });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const granted = new Set(valid.credentials.map((c) => c.name));
|
|
137
|
+
const missing = required.find((name) => !granted.has(name));
|
|
138
|
+
if (missing) throw deny(missing, 'not_granted', []);
|
|
139
|
+
|
|
140
|
+
const { home, dirs } = await makeHome(root);
|
|
141
|
+
const cleanup = () => rm(home, { recursive: true, force: true });
|
|
142
|
+
const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs };
|
|
143
|
+
const values = [];
|
|
144
|
+
try {
|
|
145
|
+
for (const spec of valid.credentials) {
|
|
146
|
+
let value;
|
|
147
|
+
// A resolver's error text may quote the secret it failed on; drop it.
|
|
148
|
+
try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; }
|
|
149
|
+
if (!usable(value)) throw deny(spec.name, 'unresolved', values);
|
|
150
|
+
// A value this short cannot be told apart from ordinary text, so the
|
|
151
|
+
// refusal-text reader would not redact it. Refuse it here, before spawn.
|
|
152
|
+
if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values);
|
|
153
|
+
values.push(value);
|
|
154
|
+
env[spec.name] = value;
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await cleanup();
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (typeof hook.redact === 'function') hook.redact(values);
|
|
161
|
+
const names = valid.credentials.map((c) => c.name);
|
|
162
|
+
hook.emit('credential.granted', { names });
|
|
163
|
+
return { env, home, cleanup, granted: names, ...(issued ? { grantId: issued.grantId } : {}) };
|
|
164
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A grant says what one run may hold and do: which credentials reach its env,
|
|
3
|
+
* and which tool calls it may make without asking, only after asking, or never.
|
|
4
|
+
*
|
|
5
|
+
* {
|
|
6
|
+
* credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }],
|
|
7
|
+
* tools: { allow: [pattern], ask: [pattern], deny: [pattern] },
|
|
8
|
+
* }
|
|
9
|
+
*
|
|
10
|
+
* `from: 'env'` copies baseEnv[ref ?? name]; `from: 'file'` reads the absolute
|
|
11
|
+
* path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec),
|
|
12
|
+
* with `ref` as an opaque hint for it.
|
|
13
|
+
*
|
|
14
|
+
* A tool pattern is `<kind>` or `<kind>:<title glob>`, where kind is one of
|
|
15
|
+
* ACP's ToolKind values or `*`. `execute` matches every execute call;
|
|
16
|
+
* `execute:git status*` matches execute calls whose title starts that way;
|
|
17
|
+
* `*:*.env*` matches any kind by title. Globs know `*` and `?` only.
|
|
18
|
+
*
|
|
19
|
+
* The title is text the agent chose. A title-glob deny narrows what the agent
|
|
20
|
+
* can do; a title-glob allow is only as strong as the agent's honesty about
|
|
21
|
+
* its title, so prefer kind-level allows and put execute behind ask.
|
|
22
|
+
*
|
|
23
|
+
* Precedence is deny > ask > allow, and a call no rule names is denied.
|
|
24
|
+
*
|
|
25
|
+
* `network` rules are refused for now rather than accepted and ignored: a run
|
|
26
|
+
* would believe it was fenced when nothing enforces the fence. TODO: egress
|
|
27
|
+
* allowlist once a sandbox can enforce it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const TOOL_KINDS = Object.freeze(['read', 'edit', 'delete', 'move', 'search', 'execute', 'think', 'fetch', 'other']);
|
|
31
|
+
export const CREDENTIAL_SOURCES = Object.freeze(['env', 'file', 'resolver']);
|
|
32
|
+
const RULE_LISTS = ['deny', 'ask', 'allow']; // checked in this order: the precedence
|
|
33
|
+
const VALIDATED = Symbol('validated grant');
|
|
34
|
+
|
|
35
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
|
|
36
|
+
// Names the run env is built from. A credential under one of these names would
|
|
37
|
+
// overwrite the boundary itself (a granted HOME points back at the real login).
|
|
38
|
+
const RESERVED_NAME = /^(?:HOME|PATH|TMPDIR|USER|LOGNAME|SHELL|PWD|LANG|LC_[A-Z0-9_]+|TERM|TZ|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR|XDG_[A-Z0-9_]+)$/;
|
|
39
|
+
|
|
40
|
+
const fail = (message) => { throw new Error(`grant: ${message}`); };
|
|
41
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
const refuseUnknownKeys = (value, allowed, where) => {
|
|
43
|
+
for (const key of Object.keys(value)) {
|
|
44
|
+
if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const globToRegExp = (glob) => new RegExp(`^${glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[\\s\\S]*').replace(/\?/g, '[\\s\\S]')}$`);
|
|
49
|
+
|
|
50
|
+
/** Parse once at validation so matching never re-reads a malformed pattern. */
|
|
51
|
+
const parsePattern = (pattern, where) => {
|
|
52
|
+
if (typeof pattern !== 'string' || !pattern.trim()) fail(`${where} must be a non-empty string`);
|
|
53
|
+
const colon = pattern.indexOf(':');
|
|
54
|
+
const kind = colon === -1 ? pattern : pattern.slice(0, colon);
|
|
55
|
+
const title = colon === -1 ? null : pattern.slice(colon + 1);
|
|
56
|
+
if (kind !== '*' && !TOOL_KINDS.includes(kind)) fail(`${where} names unknown tool kind "${kind}"`);
|
|
57
|
+
if (title !== null && !title) fail(`${where} has an empty title glob`);
|
|
58
|
+
return Object.freeze({ pattern, kind, title: title === null ? null : globToRegExp(title) });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const validateCredential = (credential, index, seen) => {
|
|
62
|
+
const where = `credentials[${index}]`;
|
|
63
|
+
if (!isPlainObject(credential)) fail(`${where} must be an object`);
|
|
64
|
+
refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`);
|
|
65
|
+
const { name, from, ref } = credential;
|
|
66
|
+
if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`);
|
|
67
|
+
if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`);
|
|
68
|
+
if (seen.has(name)) fail(`${where}.name ${name} is granted twice`);
|
|
69
|
+
seen.add(name);
|
|
70
|
+
if (!CREDENTIAL_SOURCES.includes(from)) fail(`${where}.from must be one of ${CREDENTIAL_SOURCES.join(', ')}`);
|
|
71
|
+
if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`);
|
|
72
|
+
if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`);
|
|
73
|
+
if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`);
|
|
74
|
+
return Object.freeze(ref === undefined ? { name, from } : { name, from, ref });
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Validate and freeze a grant. Throws on the first problem; never repairs one. */
|
|
78
|
+
export const validateGrant = (grant) => {
|
|
79
|
+
if (grant && grant[VALIDATED]) return grant;
|
|
80
|
+
if (!isPlainObject(grant)) fail('must be an object');
|
|
81
|
+
if ('network' in grant) fail('network rules are not enforced yet; remove the field rather than rely on it');
|
|
82
|
+
refuseUnknownKeys(grant, ['credentials', 'tools'], '');
|
|
83
|
+
const credentials = grant.credentials ?? [];
|
|
84
|
+
if (!Array.isArray(credentials)) fail('credentials must be an array');
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const tools = grant.tools ?? {};
|
|
87
|
+
if (!isPlainObject(tools)) fail('tools must be an object');
|
|
88
|
+
refuseUnknownKeys(tools, RULE_LISTS, 'tools.');
|
|
89
|
+
const rules = {};
|
|
90
|
+
for (const list of RULE_LISTS) {
|
|
91
|
+
const patterns = tools[list] ?? [];
|
|
92
|
+
if (!Array.isArray(patterns)) fail(`tools.${list} must be an array`);
|
|
93
|
+
rules[list] = Object.freeze(patterns.map((pattern, i) => parsePattern(pattern, `tools.${list}[${i}]`)));
|
|
94
|
+
}
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
[VALIDATED]: true,
|
|
97
|
+
credentials: Object.freeze(credentials.map((c, i) => validateCredential(c, i, seen))),
|
|
98
|
+
tools: Object.freeze(rules),
|
|
99
|
+
});
|
|
100
|
+
};
|
|
101
|
+
const matches = (parsed, kind, title) => (parsed.kind === '*' || parsed.kind === kind)
|
|
102
|
+
&& (parsed.title === null || parsed.title.test(title));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Which rule decides this tool call. ACP makes `kind` optional and means
|
|
106
|
+
* `other` when it is absent, so that is what an unknown or missing kind is.
|
|
107
|
+
* Returns { decision: 'allow'|'ask'|'deny', rule: { list, pattern } }.
|
|
108
|
+
*/
|
|
109
|
+
export const matchToolCall = (grant, toolCall = {}) => {
|
|
110
|
+
const valid = validateGrant(grant);
|
|
111
|
+
const kind = TOOL_KINDS.includes(toolCall?.kind) ? toolCall.kind : 'other';
|
|
112
|
+
const title = typeof toolCall?.title === 'string' ? toolCall.title : '';
|
|
113
|
+
for (const list of RULE_LISTS) {
|
|
114
|
+
const hit = valid.tools[list].find((parsed) => matches(parsed, kind, title));
|
|
115
|
+
if (hit) return { decision: list, rule: { list, pattern: hit.pattern } };
|
|
116
|
+
}
|
|
117
|
+
return { decision: 'deny', rule: { list: 'default', pattern: null } };
|
|
118
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a grant into ctx.decidePermission: the answer to ACP's
|
|
3
|
+
* session/request_permission, decided per tool call.
|
|
4
|
+
*
|
|
5
|
+
* allow → the allow_once option
|
|
6
|
+
* deny → the reject_once option, or cancelled when there is none
|
|
7
|
+
* ask → approve({ toolCall, grantRule }) from the host. Only an answer
|
|
8
|
+
* that says who gave it decides:
|
|
9
|
+
* { approved: true | false, by: <user id>, role, decisionCardId? },
|
|
10
|
+
* with `role` an owner role. Anything else (a bare true, 'yes',
|
|
11
|
+
* {approved: 1}, an answer naming nobody, a throw, no answer in
|
|
12
|
+
* time) is a failed ask, and the policy's default, deny, decides.
|
|
13
|
+
* An answer whose role is `pod-member` decides nothing (ADR-033 D5):
|
|
14
|
+
* the call stays parked, and approve is called again with
|
|
15
|
+
* `refused: { by, role: 'pod-member', reason: 'not_owner' }` so the
|
|
16
|
+
* card can show why and wait for the owner.
|
|
17
|
+
*
|
|
18
|
+
* allow_always and reject_always are never chosen: either would be remembered
|
|
19
|
+
* by the agent past this grant, so the next call would skip the check.
|
|
20
|
+
* An allow rule with no allow_once option therefore denies.
|
|
21
|
+
*/
|
|
22
|
+
import { traceOf } from '../trace/events.js';
|
|
23
|
+
import { argsDigestOf, PARKED_CALL_ROLES, isUserId, policyActorId } from '../record/format.js';
|
|
24
|
+
import { matchToolCall, validateGrant } from './grant.js';
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_APPROVE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
|
+
// An approver that keeps handing back pod-member answers is broken; after this
|
|
28
|
+
// many the ask fails, and the policy's default decides, as for any broken approver.
|
|
29
|
+
export const MAX_REFUSED_ANSWERS = 10;
|
|
30
|
+
|
|
31
|
+
const optionOfKind = (options, kind) => (Array.isArray(options) ? options : [])
|
|
32
|
+
.find((option) => option && option.kind === kind && typeof option.optionId === 'string');
|
|
33
|
+
|
|
34
|
+
const allowAnswer = (options) => {
|
|
35
|
+
const option = optionOfKind(options, 'allow_once');
|
|
36
|
+
return option ? { outcome: 'selected', optionId: option.optionId, kind: option.kind } : null;
|
|
37
|
+
};
|
|
38
|
+
const denyAnswer = (options) => {
|
|
39
|
+
const option = optionOfKind(options, 'reject_once');
|
|
40
|
+
return option ? { outcome: 'selected', optionId: option.optionId, kind: option.kind } : { outcome: 'cancelled' };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
44
|
+
const isText = (value) => typeof value === 'string' && value.length > 0;
|
|
45
|
+
|
|
46
|
+
// The roles a person can answer a parked call as; `policy` is not a person.
|
|
47
|
+
const ANSWER_ROLES = PARKED_CALL_ROLES.filter((role) => role !== 'policy');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* What an approver's answer says. It approves or rejects only when it is an
|
|
51
|
+
* object whose `approved` is exactly true or false and that names a user id
|
|
52
|
+
* and an owner role. Any other answer approves nothing: it is a failed ask,
|
|
53
|
+
* so the record never carries a decision nobody can be held to.
|
|
54
|
+
*/
|
|
55
|
+
const readAnswer = (answer) => {
|
|
56
|
+
if (!isPlainObject(answer) || typeof answer.approved !== 'boolean') return { verdict: 'failed' };
|
|
57
|
+
if (!isUserId(answer.by) || !ANSWER_ROLES.includes(answer.role)) return { verdict: 'failed' };
|
|
58
|
+
return {
|
|
59
|
+
verdict: answer.approved ? 'approved' : 'rejected',
|
|
60
|
+
by: answer.by,
|
|
61
|
+
role: answer.role,
|
|
62
|
+
...(isText(answer.decisionCardId) ? { decisionCardId: answer.decisionCardId } : {}),
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isPodMemberAnswer = (answer) => isPlainObject(answer) && answer.role === 'pod-member';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wait for the host's approval, bounded. Resolves { verdict, by?, role?,
|
|
70
|
+
* decisionCardId? }, verdict 'approved' | 'rejected' | 'timed_out' | 'failed'
|
|
71
|
+
* | 'aborted'; never rejects, so a broken approver becomes a denial rather
|
|
72
|
+
* than a hung or crashed turn.
|
|
73
|
+
*/
|
|
74
|
+
const awaitApproval = (approve, input, { timeoutMs, signal }) => new Promise((done) => {
|
|
75
|
+
if (typeof approve !== 'function') { done({ verdict: 'failed' }); return; }
|
|
76
|
+
if (signal?.aborted) { done({ verdict: 'aborted' }); return; }
|
|
77
|
+
let finished = false;
|
|
78
|
+
const finish = (verdict) => {
|
|
79
|
+
if (finished) return;
|
|
80
|
+
finished = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
83
|
+
done(verdict);
|
|
84
|
+
};
|
|
85
|
+
const onAbort = () => finish({ verdict: 'aborted' });
|
|
86
|
+
const timer = setTimeout(() => finish({ verdict: 'timed_out' }), timeoutMs);
|
|
87
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
88
|
+
let refusals = 0;
|
|
89
|
+
const ask = (current) => Promise.resolve()
|
|
90
|
+
.then(() => approve(current))
|
|
91
|
+
.then((answer) => {
|
|
92
|
+
if (finished) return;
|
|
93
|
+
if (!isPodMemberAnswer(answer)) { finish(readAnswer(answer)); return; }
|
|
94
|
+
// A pod member may not decide a parked call: the call stays parked and
|
|
95
|
+
// the owner is asked again, with the refusal, inside the same timeout.
|
|
96
|
+
refusals += 1;
|
|
97
|
+
if (refusals > MAX_REFUSED_ANSWERS) { finish({ verdict: 'failed' }); return; }
|
|
98
|
+
const refused = { by: isUserId(answer.by) ? answer.by : null, role: 'pod-member', reason: 'not_owner' };
|
|
99
|
+
setTimeout(() => { if (!finished) ask({ ...input, refused }); }, 0);
|
|
100
|
+
}, () => finish({ verdict: 'failed' }));
|
|
101
|
+
ask(input);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Who decided an ask, for the record. A person who answered is named by the
|
|
106
|
+
* approver; anything else (no answer in time, an answer naming nobody, a
|
|
107
|
+
* broken approver, a cancelled run) means the policy's default decided.
|
|
108
|
+
* Rule-decided calls carry no decidedBy here; the record writer fills in
|
|
109
|
+
* "policy" for any decision whose rule list is not `ask`.
|
|
110
|
+
*/
|
|
111
|
+
const decider = (approval) => {
|
|
112
|
+
if (approval.verdict === 'approved' || approval.verdict === 'rejected') {
|
|
113
|
+
return {
|
|
114
|
+
decidedBy: approval.by,
|
|
115
|
+
decidedByRole: approval.role,
|
|
116
|
+
...(approval.decisionCardId ? { decisionCardId: approval.decisionCardId } : {}),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { decidedBy: 'policy', decidedByRole: 'policy' };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const isAsker = (value) => isPlainObject(value) && isUserId(value.id) && PARKED_CALL_ROLES.includes(value.role);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Who an ask goes to, for permission.requested. `askedOf` is {id, role} or
|
|
126
|
+
* ({toolCall, grantRule}) => {id, role, decisionCardId?}. A call a
|
|
127
|
+
* rule decides is asked of the grant's policy. A function that throws or
|
|
128
|
+
* answers malformed is recorded as asked of the policy too: the record does
|
|
129
|
+
* not name a person it cannot vouch for.
|
|
130
|
+
*/
|
|
131
|
+
const askOf = (askedOf, input, policy) => {
|
|
132
|
+
if (input.grantRule?.list !== 'ask') return policy;
|
|
133
|
+
let answer = askedOf;
|
|
134
|
+
if (typeof askedOf === 'function') {
|
|
135
|
+
try { answer = askedOf(input); } catch { answer = null; }
|
|
136
|
+
}
|
|
137
|
+
if (!isAsker(answer)) return policy;
|
|
138
|
+
return {
|
|
139
|
+
id: answer.id,
|
|
140
|
+
role: answer.role,
|
|
141
|
+
...(isText(answer.decisionCardId) ? { decisionCardId: answer.decisionCardId } : {}),
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* createPermissionPolicy(grant, { approve?, trace?, approveTimeoutMs?, signal?, grantId?, askedOf? })
|
|
147
|
+
* → (request) => Promise<{outcome:'selected', optionId} | {outcome:'cancelled'}>
|
|
148
|
+
*
|
|
149
|
+
* `signal` is the run's AbortSignal: a cancelled run answers a pending ask
|
|
150
|
+
* with cancelled, which is what ACP asks of a client after session/cancel.
|
|
151
|
+
* `grantId` is recorded on each decision, to tie it to its grant.issued.
|
|
152
|
+
* With `askedOf` (above), each request is recorded first as
|
|
153
|
+
* permission.requested: the question (the tool call's title), who it is
|
|
154
|
+
* asked of, and the card it rides on. The approver receives that card id.
|
|
155
|
+
* When the tool call carries `rawInput`, both entries carry its argsDigest.
|
|
156
|
+
* Given a `trace`, the returned function has `recordsPermissions === true`.
|
|
157
|
+
*/
|
|
158
|
+
export const createPermissionPolicy = (grant, {
|
|
159
|
+
approve, trace, approveTimeoutMs = DEFAULT_APPROVE_TIMEOUT_MS, signal, grantId, askedOf,
|
|
160
|
+
} = {}) => {
|
|
161
|
+
const valid = validateGrant(grant);
|
|
162
|
+
const hook = traceOf({ trace });
|
|
163
|
+
if (askedOf !== undefined && typeof askedOf !== 'function' && !isAsker(askedOf)) {
|
|
164
|
+
throw new Error('askedOf must be {id: <user id>, role: <decidedByRole>} or a function returning one');
|
|
165
|
+
}
|
|
166
|
+
const policy = { id: policyActorId(grantId), role: 'policy' };
|
|
167
|
+
const decide = async (request = {}) => {
|
|
168
|
+
const toolCall = request?.toolCall || {};
|
|
169
|
+
const options = request?.options;
|
|
170
|
+
const { decision, rule } = matchToolCall(valid, toolCall);
|
|
171
|
+
const toolCallId = typeof toolCall.toolCallId === 'string' ? toolCall.toolCallId : null;
|
|
172
|
+
const argsDigest = argsDigestOf(toolCall.rawInput);
|
|
173
|
+
let asker = null;
|
|
174
|
+
if (askedOf !== undefined) {
|
|
175
|
+
asker = askOf(askedOf, { toolCall, grantRule: rule }, policy);
|
|
176
|
+
const fallback = `${isText(toolCall.kind) ? toolCall.kind : 'tool'} call`;
|
|
177
|
+
hook.emit('permission.requested', {
|
|
178
|
+
toolCallId,
|
|
179
|
+
options: (Array.isArray(options) ? options : []).filter(isPlainObject).map(({ optionId, kind }) => ({ optionId, kind })),
|
|
180
|
+
question: isText(toolCall.title) ? toolCall.title : fallback,
|
|
181
|
+
askedOf: { id: asker.id, role: asker.role },
|
|
182
|
+
...(grantId ? { grantId } : {}),
|
|
183
|
+
...(asker.decisionCardId ? { decisionCardId: asker.decisionCardId } : {}),
|
|
184
|
+
...(argsDigest ? { argsDigest } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
let approval;
|
|
188
|
+
let answer;
|
|
189
|
+
if (decision === 'ask') {
|
|
190
|
+
const input = { toolCall, grantRule: rule, ...(asker?.decisionCardId ? { decisionCardId: asker.decisionCardId } : {}) };
|
|
191
|
+
approval = await awaitApproval(approve, input, { timeoutMs: approveTimeoutMs, signal });
|
|
192
|
+
if (approval.verdict === 'aborted') answer = { outcome: 'cancelled' };
|
|
193
|
+
else answer = approval.verdict === 'approved' ? allowAnswer(options) : null;
|
|
194
|
+
} else if (decision === 'allow') {
|
|
195
|
+
answer = allowAnswer(options);
|
|
196
|
+
}
|
|
197
|
+
if (!answer) answer = denyAnswer(options);
|
|
198
|
+
hook.emit('permission.decided', {
|
|
199
|
+
outcome: answer.outcome,
|
|
200
|
+
...(answer.optionId ? { optionId: answer.optionId, kind: answer.kind } : {}),
|
|
201
|
+
toolCallId,
|
|
202
|
+
rule,
|
|
203
|
+
...(approval ? { approval: approval.verdict, ...decider(approval) } : {}),
|
|
204
|
+
...(grantId ? { grantId } : {}),
|
|
205
|
+
...(argsDigest ? { argsDigest } : {}),
|
|
206
|
+
});
|
|
207
|
+
return answer.outcome === 'selected' ? { outcome: 'selected', optionId: answer.optionId } : { outcome: 'cancelled' };
|
|
208
|
+
};
|
|
209
|
+
// With a trace this writes both permission entries itself, so an adapter
|
|
210
|
+
// handed it as ctx.decidePermission must not write them again.
|
|
211
|
+
if (trace) Object.defineProperty(decide, 'recordsPermissions', { value: true });
|
|
212
|
+
return decide;
|
|
213
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.175",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
@@ -32,11 +32,16 @@
|
|
|
32
32
|
"lint:fix": "eslint src --ext .js --fix",
|
|
33
33
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
34
34
|
"test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs",
|
|
35
|
+
"prepack": "node scripts/require-bundled-seats.mjs",
|
|
35
36
|
"prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
39
|
+
"@commonlyai/seats": "file:../packages/seats",
|
|
38
40
|
"commander": "^12.0.0"
|
|
39
41
|
},
|
|
42
|
+
"bundleDependencies": [
|
|
43
|
+
"@commonlyai/seats"
|
|
44
|
+
],
|
|
40
45
|
"devDependencies": {
|
|
41
46
|
"eslint": "^8.56.0",
|
|
42
47
|
"jest": "^29.7.0"
|
package/src/commands/agent.js
CHANGED
|
@@ -82,6 +82,7 @@ import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
|
82
82
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
83
83
|
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
84
84
|
import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
|
|
85
|
+
import { createSeatStop } from '../lib/seat-stop.js';
|
|
85
86
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
86
87
|
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
87
88
|
import {
|
|
@@ -1145,6 +1146,17 @@ export const runMemoryImport = async ({
|
|
|
1145
1146
|
|
|
1146
1147
|
// ── run: local-CLI wrapper loop (ADR-005) ────────────────────────────────────
|
|
1147
1148
|
|
|
1149
|
+
// TASK-293, and the rule AX entry 43 earned: a frame that says "otherwise
|
|
1150
|
+
// return NO_REPLY" must also say what the otherwise costs. Silence here is an
|
|
1151
|
+
// exact match (`silentReply` in runTurn), so a seat that checkpointed and then
|
|
1152
|
+
// ended its turn on a summary of doing nothing had the summary posted — four
|
|
1153
|
+
// times in 24 h, from two seats. No wrapper-side classifier guesses which
|
|
1154
|
+
// summaries were meant for nobody; the consequence goes where the instruction
|
|
1155
|
+
// is. Every prompt whose final text the wrapper posts to the pod carries it.
|
|
1156
|
+
// agent.ask does not: its final text goes only to the asking agent.
|
|
1157
|
+
const FINAL_TEXT_IS_POSTED = 'Your final text is posted to the pod unless it is exactly NO_REPLY. '
|
|
1158
|
+
+ 'If you did nothing, or already posted through a tool, end with NO_REPLY alone, with no summary.';
|
|
1159
|
+
|
|
1148
1160
|
const extractPrompt = (event) => {
|
|
1149
1161
|
const p = event.payload || {};
|
|
1150
1162
|
if (PROMPT_EVENT_TYPES.has(event.type)) {
|
|
@@ -1202,6 +1214,7 @@ const extractPrompt = (event) => {
|
|
|
1202
1214
|
'',
|
|
1203
1215
|
'Use this answer to continue the work you were doing.',
|
|
1204
1216
|
'Only post a concise pod update if a human needs it; otherwise return NO_REPLY.',
|
|
1217
|
+
FINAL_TEXT_IS_POSTED,
|
|
1205
1218
|
].join('\n');
|
|
1206
1219
|
}
|
|
1207
1220
|
if (event.type === 'decision.ruled') {
|
|
@@ -2248,7 +2261,8 @@ export const performRun = ({
|
|
|
2248
2261
|
...(omitted > 0
|
|
2249
2262
|
? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`]
|
|
2250
2263
|
: []),
|
|
2251
|
-
'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY.'
|
|
2264
|
+
'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY. '
|
|
2265
|
+
+ FINAL_TEXT_IS_POSTED,
|
|
2252
2266
|
].join('\n\n');
|
|
2253
2267
|
};
|
|
2254
2268
|
|
|
@@ -3836,12 +3850,16 @@ Docs:
|
|
|
3836
3850
|
// SIGTERM is the daemon's idle-boundary handoff signal. `stop()` stops
|
|
3837
3851
|
// future polls but lets the in-flight turn finish and ack before the
|
|
3838
3852
|
// event loop drains; exiting here would cut a model turn in half.
|
|
3839
|
-
|
|
3853
|
+
// seat-stop also arms the supervised-group deadline before launchd's
|
|
3854
|
+
// SIGKILL, and kills any group still live when the process exits.
|
|
3855
|
+
const seatStop = createSeatStop({ name, stop });
|
|
3856
|
+
const requestStop = (signal) => {
|
|
3840
3857
|
console.log(`\n${stamp()} [${name}] stopping after the current turn...`);
|
|
3841
|
-
|
|
3858
|
+
seatStop.requestStop(signal);
|
|
3842
3859
|
};
|
|
3843
3860
|
process.on('SIGINT', requestStop);
|
|
3844
3861
|
process.on('SIGTERM', requestStop);
|
|
3862
|
+
process.once('exit', seatStop.onExit);
|
|
3845
3863
|
});
|
|
3846
3864
|
|
|
3847
3865
|
// ── detach (ADR-005) ──────────────────────────────────────────────────────
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
+
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
3
4
|
/**
|
|
4
5
|
* claude adapter — wraps the local `claude` CLI as a Commonly agent.
|
|
5
6
|
*
|
|
@@ -301,12 +302,14 @@ const publicSkillPromptArgs = async (skillSources) => {
|
|
|
301
302
|
return { args: text ? ['--append-system-prompt', text] : [], included };
|
|
302
303
|
};
|
|
303
304
|
|
|
304
|
-
|
|
305
|
-
|
|
305
|
+
// `ownGroup` is set only under the run supervisor, which kills the child's
|
|
306
|
+
// whole group; unsupervised, the spawn options are exactly what they were.
|
|
307
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => {
|
|
308
|
+
const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
|
|
306
309
|
cwd,
|
|
307
310
|
env,
|
|
308
311
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
309
|
-
});
|
|
312
|
+
}, { ownGroup });
|
|
310
313
|
observeSpawn(proc, onSpawn, controls);
|
|
311
314
|
let stdout = '';
|
|
312
315
|
let stderr = '';
|
|
@@ -920,6 +923,7 @@ export default {
|
|
|
920
923
|
// the value a tail can echo back.
|
|
921
924
|
credentials: spawnCredentials(spawnCtx),
|
|
922
925
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
|
|
926
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
923
927
|
});
|
|
924
928
|
// TASK-440: the context this turn left, read from the transcript the
|
|
925
929
|
// child wrote under its own env (an isolated HOME for public seats).
|
|
@@ -974,6 +978,7 @@ export default {
|
|
|
974
978
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
975
979
|
credentials: spawnCredentials(retryCtx),
|
|
976
980
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
|
|
981
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
977
982
|
});
|
|
978
983
|
const contextTokens = claudeContextTokens({
|
|
979
984
|
cwd: ctx.cwd, sessionId: freshId,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { codexRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
+
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
3
4
|
/**
|
|
4
5
|
* codex adapter — wraps the local `codex` CLI as a Commonly agent.
|
|
5
6
|
*
|
|
@@ -572,14 +573,15 @@ const CODEX_PROVIDER_KEY_ENVS = [
|
|
|
572
573
|
'CODEX_API_KEY',
|
|
573
574
|
];
|
|
574
575
|
|
|
575
|
-
|
|
576
|
+
// `ownGroup`: see runClaude in claude.js.
|
|
577
|
+
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
576
578
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
577
579
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
578
580
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
579
581
|
// codex detects a TTY and uses the argv prompt directly. Setting stdin to
|
|
580
582
|
// `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
|
|
581
583
|
// argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
|
|
582
|
-
const proc = spawnImpl
|
|
584
|
+
const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
|
|
583
585
|
observeSpawn(proc, onSpawn, controls);
|
|
584
586
|
let stderr = '';
|
|
585
587
|
let timedOut = false;
|
|
@@ -745,6 +747,7 @@ export default {
|
|
|
745
747
|
// nothing left to compare a tail against.
|
|
746
748
|
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
747
749
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
|
|
750
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
748
751
|
});
|
|
749
752
|
|
|
750
753
|
let text = '';
|