@glyphteck/veyl 0.67.0 → 0.68.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/dist/account.js +2000 -429
- package/dist/accountprofiles.js +98 -5
- package/dist/auth.js +1 -1
- package/dist/cli.js +4154 -2080
- package/dist/index.js +4148 -1017
- package/docs/agents.md +9 -1
- package/docs/api.md +52 -2
- package/docs/validation.md +4 -4
- package/docs/vote-market.md +248 -0
- package/examples/codex-agent/agent-instructions.js +19 -0
- package/examples/codex-agent/agent-state.js +357 -0
- package/examples/codex-agent/chat-whitelist.js +104 -0
- package/examples/codex-agent/codex-app-server.js +737 -0
- package/examples/codex-agent/codex-config.js +13 -0
- package/examples/codex-agent/codex-input.js +89 -0
- package/examples/codex-agent/connector.js +478 -0
- package/examples/codex-agent/index.js +140 -0
- package/examples/codex-agent/instance-lock.js +359 -0
- package/examples/codex-agent/readme.md +88 -0
- package/examples/codex-agent/veyl-channel.js +603 -0
- package/package.json +12 -1
- package/readme.md +1 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import {
|
|
3
|
+
cleanProfileName,
|
|
4
|
+
defaultHomeDir,
|
|
5
|
+
} from '@glyphteck/veyl';
|
|
6
|
+
import { VEYL_AGENT_INSTRUCTIONS } from './agent-instructions.js';
|
|
7
|
+
import { openAgentState } from './agent-state.js';
|
|
8
|
+
import { createCodexAppServer } from './codex-app-server.js';
|
|
9
|
+
import { codexModelConfig } from './codex-config.js';
|
|
10
|
+
import {
|
|
11
|
+
codexInputForVeylMessage,
|
|
12
|
+
defaultVeylCliCommand,
|
|
13
|
+
} from './codex-input.js';
|
|
14
|
+
import { createAgentConnector } from './connector.js';
|
|
15
|
+
import {
|
|
16
|
+
acquireConnectorInstance,
|
|
17
|
+
codexAgentPaths,
|
|
18
|
+
} from './instance-lock.js';
|
|
19
|
+
import { openVeylAgentChannel } from './veyl-channel.js';
|
|
20
|
+
|
|
21
|
+
function cleanText(value) {
|
|
22
|
+
return String(value ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function log(event, details = {}) {
|
|
26
|
+
const suffix = Object.keys(details).length
|
|
27
|
+
? ` ${JSON.stringify(details)}`
|
|
28
|
+
: '';
|
|
29
|
+
process.stdout.write(`${new Date().toISOString()} ${event}${suffix}\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const profile = cleanProfileName(process.env.VEYL_PROFILE, '');
|
|
33
|
+
const owner = cleanText(process.env.VEYL_OWNER);
|
|
34
|
+
const network = cleanText(process.env.VEYL_NETWORK).toUpperCase() || 'REGTEST';
|
|
35
|
+
const sessionName = cleanProfileName(process.env.VEYL_SESSION, 'codex');
|
|
36
|
+
const homeDir = cleanText(process.env.VEYL_HOME) || defaultHomeDir();
|
|
37
|
+
const cwd = cleanText(process.env.CODEX_CWD) || process.cwd();
|
|
38
|
+
const veylCli = cleanText(process.env.VEYL_CLI) || defaultVeylCliCommand();
|
|
39
|
+
const { model, effort } = codexModelConfig(process.env);
|
|
40
|
+
|
|
41
|
+
if (!profile || !owner) {
|
|
42
|
+
throw new Error('VEYL_PROFILE and VEYL_OWNER are required');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const paths = codexAgentPaths({
|
|
46
|
+
homeDir,
|
|
47
|
+
profile,
|
|
48
|
+
statePath: cleanText(process.env.VEYL_AGENT_STATE),
|
|
49
|
+
});
|
|
50
|
+
const instance = await acquireConnectorInstance({
|
|
51
|
+
paths,
|
|
52
|
+
profile,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let channel = null;
|
|
56
|
+
let agent = null;
|
|
57
|
+
let connector = null;
|
|
58
|
+
let stopTask = null;
|
|
59
|
+
function stop(signal) {
|
|
60
|
+
if (stopTask) return stopTask;
|
|
61
|
+
stopTask = (async () => {
|
|
62
|
+
log('connector.stopping', { signal });
|
|
63
|
+
if (connector) {
|
|
64
|
+
await connector.close();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await Promise.allSettled([
|
|
68
|
+
channel?.close(),
|
|
69
|
+
agent?.close(),
|
|
70
|
+
]);
|
|
71
|
+
})();
|
|
72
|
+
return stopTask;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const state = await openAgentState({
|
|
77
|
+
path: paths.statePath,
|
|
78
|
+
binding: `${profile}\n${network}\n${owner.toLowerCase()}`,
|
|
79
|
+
});
|
|
80
|
+
const configuredThreadId = cleanText(process.env.VEYL_CODEX_THREAD_ID);
|
|
81
|
+
if (
|
|
82
|
+
configuredThreadId
|
|
83
|
+
&& state.threadId
|
|
84
|
+
&& configuredThreadId !== state.threadId
|
|
85
|
+
) {
|
|
86
|
+
throw new Error('VEYL_CODEX_THREAD_ID does not match the connector state');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
channel = await openVeylAgentChannel({
|
|
90
|
+
state,
|
|
91
|
+
owner,
|
|
92
|
+
profile,
|
|
93
|
+
username: cleanText(process.env.VEYL_USERNAME) || profile,
|
|
94
|
+
network,
|
|
95
|
+
homeDir,
|
|
96
|
+
temporaryRoot: paths.temporaryRoot,
|
|
97
|
+
temporaryOwner: instance,
|
|
98
|
+
sessionName,
|
|
99
|
+
log,
|
|
100
|
+
});
|
|
101
|
+
agent = createCodexAppServer({
|
|
102
|
+
codexPath: cleanText(process.env.CODEX_PATH) || 'codex',
|
|
103
|
+
threadId: configuredThreadId || state.threadId,
|
|
104
|
+
cwd,
|
|
105
|
+
model,
|
|
106
|
+
effort,
|
|
107
|
+
developerInstructions: VEYL_AGENT_INSTRUCTIONS,
|
|
108
|
+
replaceMissingThread: state.canReplaceThread,
|
|
109
|
+
serviceName: 'veyl_codex_agent',
|
|
110
|
+
});
|
|
111
|
+
connector = createAgentConnector({
|
|
112
|
+
state,
|
|
113
|
+
channel,
|
|
114
|
+
agent,
|
|
115
|
+
log,
|
|
116
|
+
inputForMessage: (message, options) => codexInputForVeylMessage(
|
|
117
|
+
message,
|
|
118
|
+
{
|
|
119
|
+
...options,
|
|
120
|
+
cli: veylCli,
|
|
121
|
+
profile,
|
|
122
|
+
session: sessionName,
|
|
123
|
+
}
|
|
124
|
+
),
|
|
125
|
+
});
|
|
126
|
+
process.once('SIGINT', () => void stop('SIGINT'));
|
|
127
|
+
process.once('SIGTERM', () => void stop('SIGTERM'));
|
|
128
|
+
log('connector.starting', {
|
|
129
|
+
profile,
|
|
130
|
+
owner,
|
|
131
|
+
network,
|
|
132
|
+
model,
|
|
133
|
+
effort,
|
|
134
|
+
threadId: configuredThreadId || state.threadId || 'new',
|
|
135
|
+
});
|
|
136
|
+
await connector.run();
|
|
137
|
+
} finally {
|
|
138
|
+
await stop('runtime-ended');
|
|
139
|
+
await instance.release();
|
|
140
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
realpath,
|
|
8
|
+
rename,
|
|
9
|
+
rmdir,
|
|
10
|
+
unlink,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import { hostname } from 'node:os';
|
|
13
|
+
import {
|
|
14
|
+
basename,
|
|
15
|
+
dirname,
|
|
16
|
+
isAbsolute,
|
|
17
|
+
join,
|
|
18
|
+
relative,
|
|
19
|
+
resolve,
|
|
20
|
+
sep,
|
|
21
|
+
} from 'node:path';
|
|
22
|
+
import process from 'node:process';
|
|
23
|
+
|
|
24
|
+
const DIR_MODE = 0o700;
|
|
25
|
+
const FILE_MODE = 0o600;
|
|
26
|
+
const OWNER_FILE = 'owner.json';
|
|
27
|
+
|
|
28
|
+
function cleanText(value) {
|
|
29
|
+
return String(value ?? '').trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cleanProfile(value) {
|
|
33
|
+
const profile = cleanText(value).toLowerCase();
|
|
34
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(profile)) {
|
|
35
|
+
throw new Error('connector profile must be filename-safe');
|
|
36
|
+
}
|
|
37
|
+
return profile;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function processIsRunning(pid) {
|
|
41
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
42
|
+
try {
|
|
43
|
+
process.kill(pid, 0);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return error?.code === 'EPERM';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function assertOwner(details, label) {
|
|
51
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
|
52
|
+
if (uid != null && details.uid !== uid) {
|
|
53
|
+
throw new Error(`${label} must belong to the current user`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assertMode(details, mode, label) {
|
|
58
|
+
if ((details.mode & 0o777) !== mode) {
|
|
59
|
+
throw new Error(`${label} must use ${mode.toString(8)} permissions`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function privateDirectory(path) {
|
|
64
|
+
await mkdir(path, { recursive: true, mode: DIR_MODE });
|
|
65
|
+
const details = await lstat(path);
|
|
66
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
67
|
+
throw new Error(`connector directory must be owner-only (0700): ${path}`);
|
|
68
|
+
}
|
|
69
|
+
assertOwner(details, 'connector directory');
|
|
70
|
+
assertMode(details, DIR_MODE, 'connector directory');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readClaim(path) {
|
|
74
|
+
let details;
|
|
75
|
+
try {
|
|
76
|
+
details = await lstat(path);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error?.code === 'ENOENT') return null;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (!details.isFile() || details.isSymbolicLink()) {
|
|
82
|
+
throw new Error('connector lock claim must be a private regular file');
|
|
83
|
+
}
|
|
84
|
+
assertOwner(details, 'connector lock claim');
|
|
85
|
+
assertMode(details, FILE_MODE, 'connector lock claim');
|
|
86
|
+
|
|
87
|
+
let value;
|
|
88
|
+
try {
|
|
89
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === 'ENOENT') return null;
|
|
92
|
+
if (error instanceof SyntaxError) {
|
|
93
|
+
throw new Error('connector lock claim is invalid', { cause: error });
|
|
94
|
+
}
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
const claim = {
|
|
98
|
+
version: Number(value?.version),
|
|
99
|
+
host: cleanText(value?.host),
|
|
100
|
+
pid: Number(value?.pid),
|
|
101
|
+
profile: cleanText(value?.profile),
|
|
102
|
+
statePath: cleanText(value?.statePath),
|
|
103
|
+
status: cleanText(value?.status),
|
|
104
|
+
token: cleanText(value?.token),
|
|
105
|
+
};
|
|
106
|
+
if (
|
|
107
|
+
claim.version !== 1
|
|
108
|
+
|| !claim.token
|
|
109
|
+
|| !claim.host
|
|
110
|
+
|| !Number.isSafeInteger(claim.pid)
|
|
111
|
+
|| claim.pid <= 0
|
|
112
|
+
|| !claim.profile
|
|
113
|
+
|| !claim.statePath
|
|
114
|
+
|| claim.status !== 'held'
|
|
115
|
+
) {
|
|
116
|
+
throw new Error('connector lock claim is invalid');
|
|
117
|
+
}
|
|
118
|
+
return claim;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function inspectLockDirectory(path) {
|
|
122
|
+
let details;
|
|
123
|
+
try {
|
|
124
|
+
details = await lstat(path);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error?.code === 'ENOENT') return null;
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
if (!details.isDirectory() || details.isSymbolicLink()) {
|
|
130
|
+
throw new Error('connector lock must be a private directory');
|
|
131
|
+
}
|
|
132
|
+
assertOwner(details, 'connector lock');
|
|
133
|
+
assertMode(details, DIR_MODE, 'connector lock');
|
|
134
|
+
const claim = await readClaim(join(path, OWNER_FILE));
|
|
135
|
+
if (!claim) throw new Error('connector lock claim is missing');
|
|
136
|
+
return claim;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function removeClaimDirectory(path) {
|
|
140
|
+
await unlink(join(path, OWNER_FILE)).catch((error) => {
|
|
141
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
142
|
+
});
|
|
143
|
+
await rmdir(path).catch((error) => {
|
|
144
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function containsPath(parent, candidate) {
|
|
149
|
+
const child = relative(parent.toLowerCase(), candidate.toLowerCase());
|
|
150
|
+
return child === ''
|
|
151
|
+
|| (!isAbsolute(child) && child !== '..' && !child.startsWith(`..${sep}`));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function canonicalFuturePath(path) {
|
|
155
|
+
const suffix = [];
|
|
156
|
+
let cursor = resolve(path);
|
|
157
|
+
while (true) {
|
|
158
|
+
try {
|
|
159
|
+
return resolve(await realpath(cursor), ...suffix);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
162
|
+
const parent = dirname(cursor);
|
|
163
|
+
if (parent === cursor) throw error;
|
|
164
|
+
suffix.unshift(basename(cursor));
|
|
165
|
+
cursor = parent;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function validateExplicitStatePath(paths) {
|
|
171
|
+
if (!paths.explicitStatePath) return;
|
|
172
|
+
const [root, statePath] = await Promise.all([
|
|
173
|
+
canonicalFuturePath(paths.root),
|
|
174
|
+
canonicalFuturePath(paths.statePath),
|
|
175
|
+
]);
|
|
176
|
+
if (
|
|
177
|
+
containsPath(join(root, 'tmp'), statePath)
|
|
178
|
+
|| containsPath(join(root, 'instance.lock'), statePath)
|
|
179
|
+
|| statePath.toLowerCase() === root.toLowerCase()
|
|
180
|
+
) {
|
|
181
|
+
throw new Error('agent state path overlaps reserved connector storage');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function codexAgentPaths(options = {}) {
|
|
186
|
+
const homeDir = resolve(cleanText(options.homeDir));
|
|
187
|
+
const profile = cleanProfile(options.profile);
|
|
188
|
+
const root = join(homeDir, 'agents', `${profile}.codex`);
|
|
189
|
+
const explicitStatePath = cleanText(options.statePath);
|
|
190
|
+
const statePath = resolve(explicitStatePath || join(root, 'state.json'));
|
|
191
|
+
const temporaryRoot = join(root, 'tmp');
|
|
192
|
+
const lockPath = join(root, 'instance.lock');
|
|
193
|
+
if (
|
|
194
|
+
statePath === root
|
|
195
|
+
|| containsPath(temporaryRoot, statePath)
|
|
196
|
+
|| containsPath(lockPath, statePath)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error('agent state path overlaps reserved connector storage');
|
|
199
|
+
}
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
root,
|
|
202
|
+
statePath,
|
|
203
|
+
temporaryRoot,
|
|
204
|
+
lockPath,
|
|
205
|
+
explicitStatePath: !!explicitStatePath,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class ConnectorInstanceLock {
|
|
210
|
+
constructor(options = {}) {
|
|
211
|
+
if (!options.paths?.root || !options.paths?.lockPath) {
|
|
212
|
+
throw new Error('connector paths required');
|
|
213
|
+
}
|
|
214
|
+
this.paths = options.paths;
|
|
215
|
+
this.profile = cleanProfile(options.profile);
|
|
216
|
+
this.host = cleanText(options.host) || hostname();
|
|
217
|
+
this.pid = Number(options.pid) || process.pid;
|
|
218
|
+
this.running = options.processIsRunning || processIsRunning;
|
|
219
|
+
this.token = null;
|
|
220
|
+
this.claimPath = null;
|
|
221
|
+
this.claim = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async createCandidate() {
|
|
225
|
+
const token = randomUUID();
|
|
226
|
+
const path = join(this.paths.root, `.instance-claim-${token}`);
|
|
227
|
+
const claimPath = join(path, OWNER_FILE);
|
|
228
|
+
const claim = {
|
|
229
|
+
version: 1,
|
|
230
|
+
token,
|
|
231
|
+
host: this.host,
|
|
232
|
+
pid: this.pid,
|
|
233
|
+
profile: this.profile,
|
|
234
|
+
statePath: this.paths.statePath,
|
|
235
|
+
status: 'held',
|
|
236
|
+
startedAt: Date.now(),
|
|
237
|
+
};
|
|
238
|
+
await mkdir(path, { mode: DIR_MODE });
|
|
239
|
+
let handle;
|
|
240
|
+
try {
|
|
241
|
+
handle = await open(claimPath, 'wx', FILE_MODE);
|
|
242
|
+
await handle.writeFile(`${JSON.stringify(claim, null, 2)}\n`);
|
|
243
|
+
await handle.sync();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
await handle?.close().catch(() => {});
|
|
246
|
+
await removeClaimDirectory(path).catch(() => {});
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
await handle.close();
|
|
250
|
+
return { claim, path };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async installCandidate(candidate) {
|
|
254
|
+
try {
|
|
255
|
+
await rename(candidate.path, this.paths.lockPath);
|
|
256
|
+
return true;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (
|
|
259
|
+
error?.code === 'EEXIST'
|
|
260
|
+
|| error?.code === 'ENOTEMPTY'
|
|
261
|
+
) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async rejectOrReclaimExisting() {
|
|
269
|
+
const claim = await inspectLockDirectory(this.paths.lockPath);
|
|
270
|
+
if (!claim) return;
|
|
271
|
+
if (claim.host !== this.host || this.running(claim.pid)) {
|
|
272
|
+
const error = new Error(
|
|
273
|
+
`Veyl Codex connector already running (${claim.host} pid ${claim.pid})`
|
|
274
|
+
);
|
|
275
|
+
error.code = 'veyl_codex_connector_running';
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const quarantine = `${this.paths.lockPath}.stale-${claim.token}`;
|
|
280
|
+
try {
|
|
281
|
+
await rename(this.paths.lockPath, quarantine);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
if (
|
|
284
|
+
error?.code === 'ENOENT'
|
|
285
|
+
|| error?.code === 'EEXIST'
|
|
286
|
+
|| error?.code === 'ENOTEMPTY'
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async acquire() {
|
|
295
|
+
if (this.token) return this;
|
|
296
|
+
await validateExplicitStatePath(this.paths);
|
|
297
|
+
await privateDirectory(this.paths.root);
|
|
298
|
+
if (this.paths.explicitStatePath) {
|
|
299
|
+
await privateDirectory(dirname(this.paths.statePath));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const candidate = await this.createCandidate();
|
|
303
|
+
try {
|
|
304
|
+
while (!await this.installCandidate(candidate)) {
|
|
305
|
+
await this.rejectOrReclaimExisting();
|
|
306
|
+
}
|
|
307
|
+
this.token = candidate.claim.token;
|
|
308
|
+
this.claimPath = join(this.paths.lockPath, OWNER_FILE);
|
|
309
|
+
this.claim = candidate.claim;
|
|
310
|
+
return this;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
await removeClaimDirectory(candidate.path).catch(() => {});
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async owns() {
|
|
318
|
+
if (!this.token || !this.claimPath) return false;
|
|
319
|
+
try {
|
|
320
|
+
const claim = await inspectLockDirectory(this.paths.lockPath);
|
|
321
|
+
return claim?.token === this.token
|
|
322
|
+
&& claim.host === this.host
|
|
323
|
+
&& claim.pid === this.pid
|
|
324
|
+
&& claim.profile === this.profile
|
|
325
|
+
&& claim.statePath === this.paths.statePath;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async assertHeld() {
|
|
332
|
+
if (!await this.owns()) {
|
|
333
|
+
throw new Error('Veyl Codex connector lock is no longer held');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async release() {
|
|
338
|
+
const token = this.token;
|
|
339
|
+
if (!token || !this.claimPath) return false;
|
|
340
|
+
const claim = await inspectLockDirectory(this.paths.lockPath);
|
|
341
|
+
if (claim?.token !== token) {
|
|
342
|
+
throw new Error('Veyl Codex connector lock changed');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const released = `${this.paths.lockPath}.released-${token}`;
|
|
346
|
+
await rename(this.paths.lockPath, released);
|
|
347
|
+
this.token = null;
|
|
348
|
+
this.claimPath = null;
|
|
349
|
+
this.claim = null;
|
|
350
|
+
await removeClaimDirectory(released);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function acquireConnectorInstance(options = {}) {
|
|
356
|
+
const lock = new ConnectorInstanceLock(options);
|
|
357
|
+
await lock.acquire();
|
|
358
|
+
return lock;
|
|
359
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# single-account Codex agent
|
|
2
|
+
|
|
3
|
+
This example runs one ordinary Veyl account continuously and connects its owner-only direct chat to one Codex app-server thread. It uses the Codex installation's existing ChatGPT authentication. It does not read, require, or store an OpenAI API key.
|
|
4
|
+
|
|
5
|
+
Veyl encryption ends at this local agent account. Accepted owner plaintext and decrypted attachments are passed to the configured Codex/OpenAI account, so that account's data controls and retention apply.
|
|
6
|
+
|
|
7
|
+
The Veyl side is harness-neutral:
|
|
8
|
+
|
|
9
|
+
- `veyl-channel.js` owns account boot, the inbound allowlist, encrypted events, attachments, live presence, writing state, final delivery, and the persistent CLI socket.
|
|
10
|
+
- `chat-whitelist.js` is the example-local owner identity check layered over Veyl's public admission policy.
|
|
11
|
+
- `connector.js` owns turn routing, steering, retries, replay checkpoints, and crash reconciliation through a five-method agent contract.
|
|
12
|
+
- `instance-lock.js` gives the exact local Veyl profile one connector owner, independent of its CLI session name, and safely reclaims dead-process claims.
|
|
13
|
+
- `agent-state.js` stores only routing identifiers, the pinned public owner identity, and processed message ids in an owner-only file. It never stores decrypted message text.
|
|
14
|
+
- `agent-instructions.js` exports the exact style and operational prompts separately, then combines them as thread-level Codex developer instructions on both creation and resume.
|
|
15
|
+
- `codex-app-server.js` is the Codex-specific harness driver.
|
|
16
|
+
- `codex-input.js` maps a Veyl message into Codex input items.
|
|
17
|
+
- `index.js` is only configuration and composition.
|
|
18
|
+
|
|
19
|
+
To adapt another harness, keep `agent-state.js`, `instance-lock.js`, `chat-whitelist.js`, `veyl-channel.js`, and `connector.js`, then replace `codex-app-server.js` plus `codex-input.js`. Veyl does not need a harness-specific backend, MCP action surface, or server-readable agent queue.
|
|
20
|
+
|
|
21
|
+
## harness adapter contract
|
|
22
|
+
|
|
23
|
+
The connector expects these five methods:
|
|
24
|
+
|
|
25
|
+
- `open()` starts or reconnects the harness and returns `{ threadId, turns }`. The thread id must remain stable for the connector's lifetime and every reconnect. A harness that allocates ids before persisting an empty thread may return `{ replacedThreadId }` only when that exact empty id is missing; the connector accepts it only while local state has no active or processed owner messages. Each recovery turn is `{ id, status, error, inputIds, finalText }`. `status` is `inProgress`, `completed`, `failed`, or `interrupted`; `inputIds` contains the exact caller-supplied `clientUserMessageId` values, never harness-internal item ids.
|
|
26
|
+
- `subscribe(listener)` returns an unsubscribe function. It emits `message.delta` as output streams, `turn.started` with `{ turnId }`, `turn.completed` with `{ turnId, status, error, inputIds, finalText }`, and `server.exit` with `{ expected }` when the harness transport stops. Completion `inputIds` are authoritative and let the connector requeue a steer whose transport outcome was uncertain.
|
|
27
|
+
- `startTurn({ input, clientUserMessageId })` accepts a new turn and returns `{ turnId }`.
|
|
28
|
+
- `steerTurn({ input, clientUserMessageId, expectedTurnId })` adds an input to that exact active turn.
|
|
29
|
+
- `close()` stops the harness and resolves only after pending calls are rejected or settled.
|
|
30
|
+
|
|
31
|
+
Error outcome is part of the contract. A normal rejected promise means the harness definitely did not accept that start or steer. If a timeout, disconnect, or process failure makes acceptance uncertain, the rejection must carry `error.outcomeUnknown === true`. The connector then reconciles through `open()` and the persisted client input ids instead of duplicating a possibly accepted action. `finalText` is delivered as a successful answer only when `status === 'completed'`; failed and interrupted turns get explicit failure responses. The connector and driver tests are executable conformance examples for these semantics.
|
|
32
|
+
|
|
33
|
+
## setup
|
|
34
|
+
|
|
35
|
+
Install the SDK and Codex CLI, then authenticate Codex once with your normal ChatGPT account:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install @glyphteck/veyl
|
|
39
|
+
codex login
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The Codex binary bundled with the ChatGPT macOS app also works; point `CODEX_PATH` at it if `codex` is not on `PATH`.
|
|
43
|
+
|
|
44
|
+
Run the packaged example:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
VEYL_PROFILE=my-agent \
|
|
48
|
+
VEYL_USERNAME=myagent \
|
|
49
|
+
VEYL_OWNER=@myaccount \
|
|
50
|
+
VEYL_NETWORK=MAINNET \
|
|
51
|
+
CODEX_CWD=/absolute/path/to/workspace \
|
|
52
|
+
node node_modules/@glyphteck/veyl/examples/codex-agent/index.js
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On its first run, the process creates the Veyl account and vault and saves both generated keys in the normal owner-only Veyl profile. Account creation records the current Veyl Terms acceptance in the same creation transaction; there is no separate human click. Later runs authenticate and unlock the saved profile. Connector state, its process lock, and temporary attachment root live under `~/.veyl/agents/<profile>.codex/`, whose owner-only permissions are checked before use.
|
|
56
|
+
|
|
57
|
+
Omit `VEYL_USERNAME` after creation. `VEYL_PROFILE` and `VEYL_OWNER` remain required. The default network is `REGTEST`; set `MAINNET` for a real account. Network selects the self-custodial wallet network, not a privileged chat environment.
|
|
58
|
+
|
|
59
|
+
`VEYL_CODEX_THREAD_ID` is optional. Without it, the connector creates a dedicated Codex thread on first run and remembers the returned id. Supplying it resumes an existing thread. The Veyl-prefixed name deliberately avoids inheriting the id of a Codex task that launches the connector. The example defaults to `gpt-5.6-luna` with `low` reasoning for a fast, economical always-on chat agent. `CODEX_MODEL` and `CODEX_REASONING` can override those explicit defaults.
|
|
60
|
+
|
|
61
|
+
Useful optional variables:
|
|
62
|
+
|
|
63
|
+
| variable | purpose |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `CODEX_PATH` | Codex executable; defaults to `codex` |
|
|
66
|
+
| `VEYL_CODEX_THREAD_ID` | existing task to resume instead of creating one |
|
|
67
|
+
| `CODEX_MODEL` | model override; defaults to `gpt-5.6-luna` |
|
|
68
|
+
| `CODEX_REASONING` | reasoning-effort override; defaults to `low` |
|
|
69
|
+
| `VEYL_HOME` | Veyl profile root; defaults to `~/.veyl` |
|
|
70
|
+
| `VEYL_SESSION` | owner-only persistent CLI socket name; defaults to `codex` |
|
|
71
|
+
| `VEYL_AGENT_STATE` | explicit connector-state path; its parent must be owner-only (`0700`) |
|
|
72
|
+
| `VEYL_CLI` | command prefix shown to Codex; defaults to this package's absolute `dist/cli.js` through the current Node executable |
|
|
73
|
+
|
|
74
|
+
Use a process supervisor appropriate to the host. The process handles `SIGINT` and `SIGTERM`, clears writing state, leaves live rooms, closes the CLI socket, locks the vault, and shuts down Codex cleanly. A profile-scoped instance lock is acquired before connector state, temporary attachments, or the account runtime can be changed. A second launch fails without disturbing the running process, even if it chooses a different `VEYL_SESSION`; after a crash, its dead process claim is reclaimed automatically.
|
|
75
|
+
|
|
76
|
+
## chat and safety behavior
|
|
77
|
+
|
|
78
|
+
The configured owner is allowed by username only during first resolution. The connector then pins the owner's stable Veyl uid and chat public key and publishes `direct: closed`, `groups: closed`, plus one opaque direct grant for that owner. Closed accounts disappear from other clients' new-chat and group-invite choices, while the private pair capability still lets the owner start the direct chat. The fixed-size public grant set does not reveal which account is allowed.
|
|
79
|
+
|
|
80
|
+
Veyl's existing blocklist remains authoritative and is checked first. Blocking the owner therefore rejects the chat even though the owner has a grant. The local identity check then admits only a signed/decrypted two-member direct Welcome from the pinned owner; groups and every other sender fail closed before owner-row persistence. Existing established chats and chats this account starts outbound are not invalidated by the admission policy, but this example's event loop responds only to the pinned owner.
|
|
81
|
+
|
|
82
|
+
For each accepted owner message, the process enters the ordinary encrypted live room and stays present for the process lifetime. It publishes a stable composition id while Codex streams, renews the writing lease, stops typing at completion, and sends the durable final with the same composition id so the temporary writing row becomes the final response in place.
|
|
83
|
+
|
|
84
|
+
The same unlocked client owns an owner-only persistent CLI socket. Codex can use the complete public CLI for chat history, files, reactions, profile actions, and explicitly authorized wallet work without starting a second Veyl runtime. The connector itself delivers the final response, so the prompt tells Codex not to duplicate that message through the CLI.
|
|
85
|
+
|
|
86
|
+
Incoming messages are checkpointed only after the final Veyl send succeeds. The connector persists active input ids before every start or steer. Once a turn completes, it allocates the response cid at send time and persists it before the first delivery attempt, keeping the final below every follow-up it answers while making an ambiguous send exactly reconcilable after restart. Completed Codex output is recovered from the remembered thread and delivered with that same persisted cid. State, lock claims, and temporary files live under the profile's dedicated owner-only connector directory. Temporary decrypted attachment files are mode `0600` and only the active lock holder may remove them after the turn or shutdown.
|
|
87
|
+
|
|
88
|
+
The example sets `approvalPolicy: never` and `dangerFullAccess` because an independent local Codex agent must reach both its workspace and the owner-only Veyl CLI socket. Run it only on a host and workspace you trust. The driver explicitly declines approval and elicitation requests and fails closed on every server request it does not handle.
|