@ravefox/space 0.2.1 → 0.4.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/LICENSE +21 -205
- package/README.md +104 -31
- package/bin/space.js +113 -0
- package/lib/agents.js +181 -0
- package/lib/boardTools.js +89 -0
- package/lib/credentials.js +95 -0
- package/lib/daemon.js +294 -0
- package/lib/harness.js +166 -0
- package/lib/http.js +101 -0
- package/lib/login.js +68 -0
- package/lib/mcp.js +39 -0
- package/lib/runtime.js +253 -0
- package/lib/store.js +96 -0
- package/lib/tools.js +93 -0
- package/lib/workspaceSandbox.js +77 -0
- package/lib/workspaceTools.js +160 -0
- package/lib/workspaceWorker.js +17 -0
- package/package.json +27 -46
- package/NOTICE +0 -55
- package/assets/webhook-sdk/python/bot.py +0 -63
- package/assets/webhook-sdk/python/commonly.py +0 -232
- package/node_modules/@commonlyai/seats/README.md +0 -25
- package/node_modules/@commonlyai/seats/package.json +0 -22
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
- package/node_modules/@commonlyai/seats/src/contract.js +0 -48
- package/node_modules/@commonlyai/seats/src/failures.js +0 -44
- package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
- package/node_modules/@commonlyai/seats/src/index.js +0 -22
- package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
- package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
- package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
- package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
- package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
- package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
- package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
- package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
- package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
- package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
- package/skills/commonly/SKILL.md +0 -233
- package/src/commands/agent.js +0 -6334
- package/src/commands/daemon.js +0 -933
- package/src/commands/dev.js +0 -153
- package/src/commands/doctor.js +0 -141
- package/src/commands/inbox.js +0 -330
- package/src/commands/login.js +0 -194
- package/src/commands/mcp.js +0 -56
- package/src/commands/pod.js +0 -120
- package/src/commands/task.js +0 -279
- package/src/commands/verify.js +0 -48
- package/src/index.js +0 -99
- package/src/lib/adapter-login.js +0 -76
- package/src/lib/adapters/broker-bridge-entry.js +0 -76
- package/src/lib/adapters/claude.js +0 -2207
- package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
- package/src/lib/adapters/codex.js +0 -1898
- package/src/lib/adapters/dsh.js +0 -513
- package/src/lib/adapters/goose.js +0 -510
- package/src/lib/adapters/hermes.js +0 -18
- package/src/lib/adapters/index.js +0 -68
- package/src/lib/adapters/opencode.js +0 -1681
- package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
- package/src/lib/adapters/pi-mcp-client.mjs +0 -617
- package/src/lib/adapters/pi-shell-env.mjs +0 -41
- package/src/lib/adapters/pi.js +0 -1025
- package/src/lib/adapters/qwen.js +0 -523
- package/src/lib/adapters/seat-model.js +0 -8
- package/src/lib/adapters/stub.js +0 -1
- package/src/lib/adapters/tested-version.js +0 -106
- package/src/lib/api.js +0 -205
- package/src/lib/claim-outcome.js +0 -128
- package/src/lib/claimed-work-gate.js +0 -73
- package/src/lib/cli-hint.js +0 -33
- package/src/lib/commit-attribution.js +0 -391
- package/src/lib/config.js +0 -155
- package/src/lib/content-refusal.js +0 -94
- package/src/lib/credential-file.js +0 -208
- package/src/lib/daemon-agent-states.js +0 -95
- package/src/lib/daemon-hint.js +0 -18
- package/src/lib/daemon-logs.js +0 -43
- package/src/lib/daemon-service-windows.js +0 -263
- package/src/lib/daemon-service.js +0 -486
- package/src/lib/daemon-state.js +0 -84
- package/src/lib/daemon-store.js +0 -119
- package/src/lib/daemon-supervisor.js +0 -629
- package/src/lib/daemon-watch.js +0 -115
- package/src/lib/declared-mcp-guard.js +0 -284
- package/src/lib/default-environment.js +0 -207
- package/src/lib/device-login.js +0 -126
- package/src/lib/digest-frame.js +0 -151
- package/src/lib/doctor.js +0 -291
- package/src/lib/egress-proxy.js +0 -160
- package/src/lib/enforcement.js +0 -946
- package/src/lib/environment.js +0 -681
- package/src/lib/first-output-watchdog.js +0 -191
- package/src/lib/git-readonly-server.js +0 -138
- package/src/lib/git-readonly.js +0 -463
- package/src/lib/git-remote-commonly.mjs +0 -100
- package/src/lib/grant-broker-guard.js +0 -345
- package/src/lib/hooks-config.js +0 -238
- package/src/lib/host-gh-broker.js +0 -152
- package/src/lib/host-gh-shim.mjs +0 -98
- package/src/lib/inbox.js +0 -483
- package/src/lib/instance-proxy.js +0 -76
- package/src/lib/machine-host.js +0 -29
- package/src/lib/mcp-credential-delivery.js +0 -121
- package/src/lib/mcp-first-run.js +0 -50
- package/src/lib/mcp-home.js +0 -644
- package/src/lib/mcp-ready.js +0 -68
- package/src/lib/mcp-server-version.js +0 -123
- package/src/lib/mcp-token.js +0 -36
- package/src/lib/mcp-warm-child.mjs +0 -412
- package/src/lib/memory-bridge.js +0 -191
- package/src/lib/memory-import.js +0 -156
- package/src/lib/missing-harness.js +0 -31
- package/src/lib/model-unavailable.js +0 -154
- package/src/lib/native-runtime-inventory.js +0 -40
- package/src/lib/outsider-grant-mcp.js +0 -82
- package/src/lib/own-key-launch.js +0 -42
- package/src/lib/pod-catch-up.js +0 -55
- package/src/lib/pod-focus.js +0 -224
- package/src/lib/pod-tail.js +0 -68
- package/src/lib/poll-liveness.js +0 -103
- package/src/lib/poll-retry.js +0 -81
- package/src/lib/poller.js +0 -200
- package/src/lib/provider-proxy.js +0 -42
- package/src/lib/public-seat-mcp-flag.js +0 -36
- package/src/lib/public-tool-error.js +0 -60
- package/src/lib/public-withheld-tools.js +0 -48
- package/src/lib/reply-receipt.js +0 -55
- package/src/lib/resolve-on-path.js +0 -22
- package/src/lib/runtime-inventory.js +0 -56
- package/src/lib/runtime-observation.js +0 -37
- package/src/lib/sandbox/bwrap.js +0 -275
- package/src/lib/sandbox/codex-perimeter.js +0 -16
- package/src/lib/sandbox/host-check.js +0 -268
- package/src/lib/sandbox/mode.js +0 -49
- package/src/lib/sandbox/opencode-loopback.mjs +0 -68
- package/src/lib/sandbox/seatbelt.js +0 -684
- package/src/lib/sandbox/turn-dir.js +0 -56
- package/src/lib/sandbox/windows-unsandboxed.js +0 -183
- package/src/lib/sandbox/workspace-git-config.js +0 -243
- package/src/lib/sandbox/workspace-pin.js +0 -158
- package/src/lib/seat-runtime-controls.js +0 -61
- package/src/lib/seat-stop.js +0 -97
- package/src/lib/server-skills.js +0 -32
- package/src/lib/session-recycle.js +0 -219
- package/src/lib/session-store.js +0 -215
- package/src/lib/skill-prompt.js +0 -74
- package/src/lib/skills-import.js +0 -113
- package/src/lib/spawn-credential.js +0 -402
- package/src/lib/spawn-retry.js +0 -311
- package/src/lib/stable-install.js +0 -176
- package/src/lib/supervised-turn.js +0 -352
- package/src/lib/task.js +0 -235
- package/src/lib/turn-failure-notice.js +0 -177
- package/src/lib/unconfined-seat-guard.js +0 -94
- package/src/lib/update-check.js +0 -192
- package/src/lib/upstream-refusal.js +0 -1
- package/src/lib/webhook-server.js +0 -109
- package/src/lib/windows-command.js +0 -393
- package/src/lib/workspace-git.js +0 -87
- package/src/lib/workspace-trust.js +0 -162
|
@@ -1,391 +0,0 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
accessSync,
|
|
5
|
-
closeSync,
|
|
6
|
-
constants,
|
|
7
|
-
fstatSync,
|
|
8
|
-
lstatSync,
|
|
9
|
-
mkdtempSync,
|
|
10
|
-
openSync,
|
|
11
|
-
readFileSync,
|
|
12
|
-
readlinkSync,
|
|
13
|
-
rmSync,
|
|
14
|
-
statSync,
|
|
15
|
-
writeFileSync,
|
|
16
|
-
} from 'node:fs';
|
|
17
|
-
import { tmpdir } from 'node:os';
|
|
18
|
-
import { dirname, join, resolve } from 'node:path';
|
|
19
|
-
import { spawnSync } from 'node:child_process';
|
|
20
|
-
|
|
21
|
-
const GIT_HOOKS = [
|
|
22
|
-
'applypatch-msg',
|
|
23
|
-
'pre-applypatch',
|
|
24
|
-
'post-applypatch',
|
|
25
|
-
'pre-commit',
|
|
26
|
-
'pre-merge-commit',
|
|
27
|
-
'prepare-commit-msg',
|
|
28
|
-
'commit-msg',
|
|
29
|
-
'post-commit',
|
|
30
|
-
'pre-rebase',
|
|
31
|
-
'post-checkout',
|
|
32
|
-
'post-merge',
|
|
33
|
-
'pre-push',
|
|
34
|
-
'pre-receive',
|
|
35
|
-
'update',
|
|
36
|
-
'proc-receive',
|
|
37
|
-
'post-receive',
|
|
38
|
-
'post-update',
|
|
39
|
-
'reference-transaction',
|
|
40
|
-
'push-to-checkout',
|
|
41
|
-
'pre-auto-gc',
|
|
42
|
-
'post-rewrite',
|
|
43
|
-
'sendemail-validate',
|
|
44
|
-
'fsmonitor-watchman',
|
|
45
|
-
'p4-pre-submit',
|
|
46
|
-
'p4-prepare-changelist',
|
|
47
|
-
'p4-changelist',
|
|
48
|
-
'p4-post-changelist',
|
|
49
|
-
'post-index-change',
|
|
50
|
-
];
|
|
51
|
-
|
|
52
|
-
const cleanSingleLine = (value, fallback) => {
|
|
53
|
-
let cleaned = '';
|
|
54
|
-
for (const character of String(value ?? '')) {
|
|
55
|
-
const codePoint = character.codePointAt(0);
|
|
56
|
-
cleaned += codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
|
|
57
|
-
}
|
|
58
|
-
cleaned = cleaned
|
|
59
|
-
.replace(/[<>]/g, '')
|
|
60
|
-
.replace(/\s+/g, ' ')
|
|
61
|
-
.trim();
|
|
62
|
-
return cleaned || fallback;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
// Use an email-local-part-safe reversible id. URI escapes introduce `%`, which
|
|
66
|
-
// Git does not accept in a co-author trailer address. Keep the reversible form
|
|
67
|
-
// while it fits the 64-character email local-part limit; longer identities use
|
|
68
|
-
// a stable hash so GitHub can still lift the co-author on squash.
|
|
69
|
-
const seatAddressId = (agentName, instanceId) => {
|
|
70
|
-
const identity = JSON.stringify([
|
|
71
|
-
cleanSingleLine(agentName, 'agent'),
|
|
72
|
-
cleanSingleLine(instanceId, 'default'),
|
|
73
|
-
]);
|
|
74
|
-
const reversibleId = Buffer.from(identity).toString('base64url');
|
|
75
|
-
if (reversibleId.length <= 64) return reversibleId;
|
|
76
|
-
return `sha256-${createHash('sha256').update(identity).digest('base64url')}`;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// This probe runs before every turn and blocks the listener's event loop, so a
|
|
80
|
-
// git child that never exits (one waiting on an inherited stdin, or one whose
|
|
81
|
-
// open() the OS holds pending consent) must not stall the seat. On timeout
|
|
82
|
-
// spawnSync kills the child and sets result.error; the wrapper hook then
|
|
83
|
-
// resolves the original hooks itself at commit time.
|
|
84
|
-
const GIT_PROBE_TIMEOUT_MS = 5000;
|
|
85
|
-
|
|
86
|
-
const directoryOrNull = (path) => {
|
|
87
|
-
try {
|
|
88
|
-
return statSync(path).isDirectory() ? path : null;
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
// A regular file's text, or null. The open does not block, so a FIFO or a
|
|
95
|
-
// device where git keeps a file is refused instead of hanging the caller.
|
|
96
|
-
const readRegularFile = (path) => {
|
|
97
|
-
let fd = null;
|
|
98
|
-
try {
|
|
99
|
-
fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
100
|
-
return fstatSync(fd).isFile() ? readFileSync(fd, 'utf8') : null;
|
|
101
|
-
} catch {
|
|
102
|
-
return null;
|
|
103
|
-
} finally {
|
|
104
|
-
if (fd !== null) closeSync(fd);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
// One git config value: quotes group, a backslash escapes the next character,
|
|
109
|
-
// and an unquoted # or ; starts a comment. Whitespace around the value is
|
|
110
|
-
// dropped unless it is quoted.
|
|
111
|
-
const configValue = (raw) => {
|
|
112
|
-
let value = '';
|
|
113
|
-
let kept = 0;
|
|
114
|
-
let quoted = false;
|
|
115
|
-
for (let index = 0; index < raw.length; index += 1) {
|
|
116
|
-
const character = raw[index];
|
|
117
|
-
if (character === '\\' && index + 1 < raw.length) {
|
|
118
|
-
index += 1;
|
|
119
|
-
value += raw[index];
|
|
120
|
-
kept = value.length;
|
|
121
|
-
} else if (character === '"') {
|
|
122
|
-
quoted = !quoted;
|
|
123
|
-
} else if (!quoted && (character === '#' || character === ';')) {
|
|
124
|
-
break;
|
|
125
|
-
} else if (quoted || value || !/\s/.test(character)) {
|
|
126
|
-
value += character;
|
|
127
|
-
if (quoted) kept = value.length;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return value.slice(0, kept) + value.slice(kept).trimEnd();
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// The last core.hooksPath in one config file, or null. `include` and
|
|
134
|
-
// conditional includes are not followed.
|
|
135
|
-
const configuredHooksPath = (configPath) => {
|
|
136
|
-
const text = readRegularFile(configPath);
|
|
137
|
-
if (text === null) return null;
|
|
138
|
-
let inCore = false;
|
|
139
|
-
let hooksPath = null;
|
|
140
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
141
|
-
let line = rawLine.trim();
|
|
142
|
-
const section = /^\[\s*([A-Za-z0-9.-]+)\s*("[^"]*")?\s*\]/.exec(line);
|
|
143
|
-
if (section) {
|
|
144
|
-
inCore = section[1].toLowerCase() === 'core' && !section[2];
|
|
145
|
-
line = line.slice(section[0].length).trim();
|
|
146
|
-
}
|
|
147
|
-
const entry = inCore && /^hookspath\s*=(.*)$/i.exec(line);
|
|
148
|
-
if (entry) hooksPath = configValue(entry[1]);
|
|
149
|
-
}
|
|
150
|
-
return hooksPath || null;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// Git opens a git directory only if its HEAD names a ref or an object id and
|
|
154
|
-
// its common directory holds objects/ and refs/.
|
|
155
|
-
const isGitDirectory = (gitDir, commonDir) => {
|
|
156
|
-
if (!directoryOrNull(join(commonDir, 'objects')) || !directoryOrNull(join(commonDir, 'refs'))) {
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
const head = join(gitDir, 'HEAD');
|
|
160
|
-
try {
|
|
161
|
-
if (lstatSync(head).isSymbolicLink()) return readlinkSync(head).startsWith('refs/');
|
|
162
|
-
const line = (readRegularFile(head) ?? '').split('\n')[0];
|
|
163
|
-
return /^ref:\s*refs\//.test(line) || /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(line);
|
|
164
|
-
} catch {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
// Git refuses a repository whose files another user owns, unless global
|
|
170
|
-
// safe.directory config allows it. That config is not read here, so such a
|
|
171
|
-
// repository yields no hooks, which is git's own answer without it.
|
|
172
|
-
const ownedByThisUser = (path) => {
|
|
173
|
-
if (typeof process.geteuid !== 'function') return false;
|
|
174
|
-
try {
|
|
175
|
-
return lstatSync(path).uid === process.geteuid();
|
|
176
|
-
} catch {
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
// The hooks directory a commit in `cwd` would use, read from the repository's
|
|
182
|
-
// own files without running git: core.hooksPath from the repository config,
|
|
183
|
-
// else the hooks folder of the common git directory (a linked worktree shares
|
|
184
|
-
// its main repository's). Global config and includes are not read. `~` expands
|
|
185
|
-
// against the HOME the probe gave git, as git itself would. The nearest .git
|
|
186
|
-
// must be a repository this user owns, or there are no hooks. Git would walk
|
|
187
|
-
// on past a .git directory that is not a repository, and this stops there.
|
|
188
|
-
const hooksPathFromGitFiles = (cwd, env) => {
|
|
189
|
-
let top = resolve(cwd);
|
|
190
|
-
let entry = join(top, '.git');
|
|
191
|
-
let entryStat = null;
|
|
192
|
-
for (;;) {
|
|
193
|
-
try {
|
|
194
|
-
entryStat = statSync(entry);
|
|
195
|
-
break;
|
|
196
|
-
} catch {
|
|
197
|
-
if (dirname(top) === top) return null;
|
|
198
|
-
top = dirname(top);
|
|
199
|
-
entry = join(top, '.git');
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
// Ownership is settled before any of the repository's files is read.
|
|
203
|
-
if (!ownedByThisUser(top) || !ownedByThisUser(entry)) return null;
|
|
204
|
-
let gitDir = entry;
|
|
205
|
-
if (entryStat.isFile()) {
|
|
206
|
-
const pointer = /^gitdir:\s*(.+?)\s*$/m.exec(readRegularFile(entry) ?? '');
|
|
207
|
-
if (!pointer) return null;
|
|
208
|
-
gitDir = resolve(top, pointer[1]);
|
|
209
|
-
if (!ownedByThisUser(gitDir)) return null;
|
|
210
|
-
}
|
|
211
|
-
// A main repository has no commondir file.
|
|
212
|
-
const commonDirFile = readRegularFile(join(gitDir, 'commondir'));
|
|
213
|
-
const commonDir = commonDirFile === null ? gitDir : resolve(gitDir, commonDirFile.trim());
|
|
214
|
-
if (!isGitDirectory(gitDir, commonDir)) return null;
|
|
215
|
-
const configured = configuredHooksPath(join(commonDir, 'config'));
|
|
216
|
-
if (!configured) return directoryOrNull(join(commonDir, 'hooks'));
|
|
217
|
-
if (configured === '~' || configured.startsWith('~/')) {
|
|
218
|
-
return env.HOME ? directoryOrNull(join(env.HOME, configured.slice(1))) : null;
|
|
219
|
-
}
|
|
220
|
-
// Git runs hooks from the top of the working tree, so a relative path
|
|
221
|
-
// resolves there.
|
|
222
|
-
return directoryOrNull(resolve(top, configured));
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
const resolveOriginalHooksPath = (cwd, env) => {
|
|
226
|
-
const result = spawnSync(
|
|
227
|
-
'git',
|
|
228
|
-
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
229
|
-
{
|
|
230
|
-
cwd,
|
|
231
|
-
env,
|
|
232
|
-
encoding: 'utf8',
|
|
233
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
234
|
-
timeout: GIT_PROBE_TIMEOUT_MS,
|
|
235
|
-
killSignal: 'SIGKILL',
|
|
236
|
-
},
|
|
237
|
-
);
|
|
238
|
-
if (result.error?.code === 'ETIMEDOUT') {
|
|
239
|
-
// The open() that stalled git would stall this process too, and a
|
|
240
|
-
// synchronous read cannot be timed out, so nothing is read here. A git
|
|
241
|
-
// that cannot open the repository cannot commit in it either.
|
|
242
|
-
// eslint-disable-next-line no-console
|
|
243
|
-
console.warn(`[commit-attribution] git did not answer in ${GIT_PROBE_TIMEOUT_MS / 1000}s in ${cwd}, `
|
|
244
|
-
+ 'so a confined seat is not granted this repository\'s hooks this turn');
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
if (result.error || result.status !== 0 || !result.stdout?.trim()) {
|
|
248
|
-
// A confined seat can only run the hooks it is granted. A hook it is not
|
|
249
|
-
// granted is skipped without a word, or refuses the commit where the jail
|
|
250
|
-
// can see the file but not run it. So read the hooks directory from the
|
|
251
|
-
// repository's files instead.
|
|
252
|
-
const fallback = hooksPathFromGitFiles(cwd, env);
|
|
253
|
-
if (fallback) {
|
|
254
|
-
const reason = result.error?.code || `exit ${result.status}`;
|
|
255
|
-
// eslint-disable-next-line no-console
|
|
256
|
-
console.warn(`[commit-attribution] git could not report the hooks directory (${reason}), `
|
|
257
|
-
+ `so ${fallback} was read from the repository's files`);
|
|
258
|
-
}
|
|
259
|
-
return fallback;
|
|
260
|
-
}
|
|
261
|
-
// Only git's newline ends the line, since a path can end in a space.
|
|
262
|
-
return directoryOrNull(resolve(result.stdout.replace(/\r?\n$/, '')));
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const resolveGitConfigCount = (env) => {
|
|
266
|
-
const raw = env.GIT_CONFIG_COUNT ?? '0';
|
|
267
|
-
if (!/^(0|[1-9]\d*)$/.test(String(raw))) {
|
|
268
|
-
throw new Error(`cannot enable commit attribution: GIT_CONFIG_COUNT is invalid (${raw})`);
|
|
269
|
-
}
|
|
270
|
-
const count = Number(raw);
|
|
271
|
-
if (!Number.isSafeInteger(count)) {
|
|
272
|
-
throw new Error('cannot enable commit attribution: GIT_CONFIG_COUNT is out of range');
|
|
273
|
-
}
|
|
274
|
-
return count;
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
const wrapperHook = () => `#!/bin/sh
|
|
278
|
-
# Space CLI commit attribution. Preserve the repository's configured hooks.
|
|
279
|
-
hook_name=\${0##*/}
|
|
280
|
-
config_index=$COMMONLY_AGENT_GIT_CONFIG_INDEX
|
|
281
|
-
base_count=$COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT
|
|
282
|
-
case "$config_index:$base_count" in
|
|
283
|
-
*[!0-9:]*|:*) exit 1 ;;
|
|
284
|
-
esac
|
|
285
|
-
GIT_CONFIG_COUNT=$base_count
|
|
286
|
-
export GIT_CONFIG_COUNT
|
|
287
|
-
unset "GIT_CONFIG_KEY_$config_index" "GIT_CONFIG_VALUE_$config_index"
|
|
288
|
-
|
|
289
|
-
# Read the target repository's file-backed config while excluding process-level
|
|
290
|
-
# GIT_CONFIG_* overlays inherited from the workspace seat.
|
|
291
|
-
if original_hooks=$(GIT_CONFIG_COUNT=0 git config --path --get core.hooksPath 2>/dev/null); then
|
|
292
|
-
:
|
|
293
|
-
else
|
|
294
|
-
git_common_dir=$(GIT_CONFIG_COUNT=0 git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || git_common_dir=
|
|
295
|
-
original_hooks=
|
|
296
|
-
if [ -n "$git_common_dir" ]; then
|
|
297
|
-
original_hooks="$git_common_dir/hooks"
|
|
298
|
-
fi
|
|
299
|
-
fi
|
|
300
|
-
if [ -n "$original_hooks" ] && [ "$original_hooks" != "$COMMONLY_AGENT_HOOKS_PATH" ]; then
|
|
301
|
-
original_hook="$original_hooks/$hook_name"
|
|
302
|
-
if [ -x "$original_hook" ]; then
|
|
303
|
-
"$original_hook" "$@"
|
|
304
|
-
hook_status=$?
|
|
305
|
-
if [ "$hook_status" -ne 0 ]; then
|
|
306
|
-
exit "$hook_status"
|
|
307
|
-
fi
|
|
308
|
-
fi
|
|
309
|
-
fi
|
|
310
|
-
|
|
311
|
-
if [ "$hook_name" = "prepare-commit-msg" ] && [ -n "$1" ]; then
|
|
312
|
-
git interpret-trailers --in-place --if-exists=addIfDifferent \\
|
|
313
|
-
--trailer "Co-authored-by: $COMMONLY_AGENT_SEAT_NAME <$COMMONLY_AGENT_SEAT_ID@agents.commonly.invalid>" \\
|
|
314
|
-
--trailer "Agent-Model: $COMMONLY_AGENT_ADAPTER/$COMMONLY_AGENT_MODEL/$COMMONLY_AGENT_EFFORT" \\
|
|
315
|
-
"$1"
|
|
316
|
-
exit $?
|
|
317
|
-
fi
|
|
318
|
-
exit 0
|
|
319
|
-
`;
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* Install a per-spawn Git hook proxy and return an environment for the CLI
|
|
323
|
-
* child. The proxy delegates every standard hook to the repository's original
|
|
324
|
-
* hook directory, then adds Commonly trailers after an existing
|
|
325
|
-
* prepare-commit-msg hook has had its say.
|
|
326
|
-
*/
|
|
327
|
-
export const prepareCommitAttribution = ({
|
|
328
|
-
cwd,
|
|
329
|
-
env = process.env,
|
|
330
|
-
agentName,
|
|
331
|
-
displayName,
|
|
332
|
-
instanceId = 'default',
|
|
333
|
-
adapter,
|
|
334
|
-
model,
|
|
335
|
-
effort,
|
|
336
|
-
}) => {
|
|
337
|
-
const configIndex = resolveGitConfigCount(env);
|
|
338
|
-
const originalHooksPath = resolveOriginalHooksPath(cwd, env);
|
|
339
|
-
const hooksDirectory = mkdtempSync(join(tmpdir(), 'commonly-agent-hooks-'));
|
|
340
|
-
try {
|
|
341
|
-
const hookSource = wrapperHook();
|
|
342
|
-
for (const hookName of GIT_HOOKS) {
|
|
343
|
-
const hookPath = join(hooksDirectory, hookName);
|
|
344
|
-
writeFileSync(hookPath, hookSource, { encoding: 'utf8', mode: 0o700 });
|
|
345
|
-
accessSync(hookPath, constants.X_OK);
|
|
346
|
-
}
|
|
347
|
-
} catch (error) {
|
|
348
|
-
rmSync(hooksDirectory, { recursive: true, force: true });
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const safeAdapter = cleanSingleLine(adapter, 'unknown');
|
|
353
|
-
const metadataEnv = {
|
|
354
|
-
COMMONLY_AGENT_GIT_CONFIG_INDEX: String(configIndex),
|
|
355
|
-
COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: String(configIndex),
|
|
356
|
-
COMMONLY_AGENT_HOOKS_PATH: hooksDirectory,
|
|
357
|
-
// The sandbox adapters allow the workspace's existing hook files. The
|
|
358
|
-
// proxy resolves the target repo's path at hook time, and a confined seat
|
|
359
|
-
// cannot run a hook outside what it was granted.
|
|
360
|
-
...(originalHooksPath ? { COMMONLY_AGENT_ORIGINAL_HOOKS_PATH: originalHooksPath } : {}),
|
|
361
|
-
COMMONLY_AGENT_SEAT_NAME: cleanSingleLine(displayName || agentName, 'agent'),
|
|
362
|
-
COMMONLY_AGENT_SEAT_ID: seatAddressId(agentName, instanceId),
|
|
363
|
-
COMMONLY_AGENT_ADAPTER: safeAdapter,
|
|
364
|
-
COMMONLY_AGENT_MODEL: cleanSingleLine(model, 'default'),
|
|
365
|
-
COMMONLY_AGENT_EFFORT: cleanSingleLine(effort, 'default'),
|
|
366
|
-
};
|
|
367
|
-
const attributionEnv = {
|
|
368
|
-
...env,
|
|
369
|
-
GIT_CONFIG_COUNT: String(configIndex + 1),
|
|
370
|
-
[`GIT_CONFIG_KEY_${configIndex}`]: 'core.hooksPath',
|
|
371
|
-
[`GIT_CONFIG_VALUE_${configIndex}`]: hooksDirectory,
|
|
372
|
-
...metadataEnv,
|
|
373
|
-
};
|
|
374
|
-
const sandboxEnv = {
|
|
375
|
-
...metadataEnv,
|
|
376
|
-
GIT_CONFIG_COUNT: '1',
|
|
377
|
-
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
|
378
|
-
GIT_CONFIG_VALUE_0: hooksDirectory,
|
|
379
|
-
COMMONLY_AGENT_GIT_CONFIG_INDEX: '0',
|
|
380
|
-
COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: '0',
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
return {
|
|
384
|
-
env: attributionEnv,
|
|
385
|
-
sandboxEnv,
|
|
386
|
-
hooksDirectory,
|
|
387
|
-
cleanup: () => rmSync(hooksDirectory, { recursive: true, force: true }),
|
|
388
|
-
};
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
export const supportedGitHooks = () => [...GIT_HOOKS];
|
package/src/lib/config.js
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Config management — ~/.commonly/config.json
|
|
3
|
-
*
|
|
4
|
-
* Stores auth tokens and instance URLs across sessions.
|
|
5
|
-
* Multiple instances supported (default, local, custom).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'fs';
|
|
9
|
-
import { join } from 'path';
|
|
10
|
-
import { homedir } from 'os';
|
|
11
|
-
|
|
12
|
-
const CONFIG_DIR = join(homedir(), '.commonly');
|
|
13
|
-
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
14
|
-
|
|
15
|
-
const DEFAULT_INSTANCE_URL = 'https://space.ravefox.dev';
|
|
16
|
-
const LOCAL_INSTANCE_URL = 'http://localhost:5000';
|
|
17
|
-
|
|
18
|
-
const read = () => {
|
|
19
|
-
if (!existsSync(CONFIG_FILE)) return { instances: {}, active: 'default' };
|
|
20
|
-
try {
|
|
21
|
-
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
|
|
22
|
-
} catch {
|
|
23
|
-
return { instances: {}, active: 'default' };
|
|
24
|
-
}
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// The file holds the login's bearer token, so only its owner may read it. The
|
|
28
|
-
// mode on create is subject to the umask, and a file written before this fix is
|
|
29
|
-
// still 0644, so every write ends with chmod. A new directory is made 0700; an
|
|
30
|
-
// existing one is left as it is, because other tools keep files there too.
|
|
31
|
-
const write = (config) => {
|
|
32
|
-
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
33
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
34
|
-
chmodSync(CONFIG_FILE, 0o600);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
export const getConfig = () => read();
|
|
38
|
-
|
|
39
|
-
const normalizeUrl = (value) => (value || '').replace(/\/$/, '').toLowerCase();
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Resolve an instance identifier to the saved instance record.
|
|
43
|
-
*
|
|
44
|
-
* Accepts either form:
|
|
45
|
-
* - A saved key: 'dev', 'default', 'local'
|
|
46
|
-
* - A URL: 'https://api.commonly.me' (any case; trailing / ok)
|
|
47
|
-
* - null: falls back to config.active
|
|
48
|
-
*
|
|
49
|
-
* Historically `resolveInstanceUrl` treated the arg as a URL and `getToken`
|
|
50
|
-
* treated it as a key — so `--instance dev` and `--instance <url>` each
|
|
51
|
-
* worked for exactly one of URL-resolution or token-lookup and broke the
|
|
52
|
-
* other. Funneling both through this helper closes that asymmetry.
|
|
53
|
-
*
|
|
54
|
-
* URL-shaped inputs (http[s]://) go through URL resolution first so a
|
|
55
|
-
* hypothetical key named `"https-backup"` can't collide with a real URL.
|
|
56
|
-
* For an unknown URL (no saved match) we return the URL with a null token,
|
|
57
|
-
* which is needed for login bootstrap (there is no saved instance yet) and
|
|
58
|
-
* for unauthenticated probe calls.
|
|
59
|
-
*/
|
|
60
|
-
export const resolveInstance = (identifier = null) => {
|
|
61
|
-
const config = read();
|
|
62
|
-
|
|
63
|
-
if (!identifier) {
|
|
64
|
-
const key = config.active || 'default';
|
|
65
|
-
const inst = config.instances[key];
|
|
66
|
-
return inst ? { key, ...inst } : null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// URL-shaped inputs: resolve by URL first. Case-insensitive match so that
|
|
70
|
-
// `HTTPS://API-DEV.commonly.me/` finds a record saved as the lowercased
|
|
71
|
-
// `https://api.commonly.me`.
|
|
72
|
-
const looksLikeUrl = /^https?:\/\//i.test(identifier);
|
|
73
|
-
if (looksLikeUrl) {
|
|
74
|
-
const normalized = normalizeUrl(identifier);
|
|
75
|
-
const matches = Object.entries(config.instances)
|
|
76
|
-
.filter(([, v]) => normalizeUrl(v.url) === normalized);
|
|
77
|
-
if (matches.length > 0) {
|
|
78
|
-
// Several keys can share one URL (`dev` and `default` both pointing at
|
|
79
|
-
// api.commonly.me after a re-login under a new key). The first entry in
|
|
80
|
-
// file order is then whichever was saved FIRST — the stale one — and
|
|
81
|
-
// every command that resolves by the token file's instanceUrl
|
|
82
|
-
// (detach, wake) got a 401 from a token the user had already replaced.
|
|
83
|
-
// Prefer the active instance, then the most recently saved.
|
|
84
|
-
const active = matches.find(([key]) => key === config.active);
|
|
85
|
-
const [key, value] = active || matches
|
|
86
|
-
.slice()
|
|
87
|
-
.sort(([, a], [, b]) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')))[0];
|
|
88
|
-
return { key, ...value };
|
|
89
|
-
}
|
|
90
|
-
// Unknown URL — still usable for bootstrapping. Preserve the caller's
|
|
91
|
-
// original case in the returned URL (some servers are case-sensitive on
|
|
92
|
-
// paths; we only lowercase for match comparison above).
|
|
93
|
-
return { key: null, url: identifier.replace(/\/$/, ''), token: null };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Key-shaped inputs: exact lookup only.
|
|
97
|
-
if (config.instances[identifier]) {
|
|
98
|
-
return { key: identifier, ...config.instances[identifier] };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Unknown key — caller falls back to defaults.
|
|
102
|
-
return null;
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
// Alias kept for existing imports (login.js). Functionally identical to
|
|
106
|
-
// resolveInstance; callers using this name pre-date the refactor.
|
|
107
|
-
export const getActiveInstance = (instanceOverride = null) => resolveInstance(instanceOverride);
|
|
108
|
-
|
|
109
|
-
export const resolveInstanceUrl = (instanceArg = null) => {
|
|
110
|
-
const inst = resolveInstance(instanceArg);
|
|
111
|
-
if (inst?.url) return inst.url.replace(/\/$/, '');
|
|
112
|
-
return DEFAULT_INSTANCE_URL;
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
export const saveInstance = ({ key = 'default', url, token, userId, username, tokenType = null }) => {
|
|
116
|
-
const config = read();
|
|
117
|
-
config.instances[key] = {
|
|
118
|
-
url: url.replace(/\/$/, ''),
|
|
119
|
-
token,
|
|
120
|
-
userId,
|
|
121
|
-
username,
|
|
122
|
-
// Device bearers are long-lived until revoked. Preserve the explicit kind
|
|
123
|
-
// so `whoami` can say that without guessing from a future token format.
|
|
124
|
-
...(tokenType ? { tokenType } : {}),
|
|
125
|
-
savedAt: new Date().toISOString(),
|
|
126
|
-
};
|
|
127
|
-
config.active = key;
|
|
128
|
-
write(config);
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
export const setActive = (key) => {
|
|
132
|
-
const config = read();
|
|
133
|
-
if (!config.instances[key]) throw new Error(`Instance '${key}' not found in config`);
|
|
134
|
-
config.active = key;
|
|
135
|
-
write(config);
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
export const getToken = (instanceOverride = null) => {
|
|
139
|
-
// Env var takes precedence (CI, scripts)
|
|
140
|
-
if (process.env.COMMONLY_TOKEN) return process.env.COMMONLY_TOKEN;
|
|
141
|
-
const inst = resolveInstance(instanceOverride);
|
|
142
|
-
return inst?.token || null;
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
export const listInstances = () => {
|
|
146
|
-
const config = read();
|
|
147
|
-
return Object.entries(config.instances).map(([key, val]) => ({
|
|
148
|
-
key,
|
|
149
|
-
...val,
|
|
150
|
-
active: key === config.active,
|
|
151
|
-
}));
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
export const LOCAL_URL = LOCAL_INSTANCE_URL;
|
|
155
|
-
export const DEFAULT_URL = DEFAULT_INSTANCE_URL;
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A model provider that refuses a request's CONTENT (2026-10-16).
|
|
3
|
-
*
|
|
4
|
-
* A security-gate seat on codex was asked to review a PR whose request carried
|
|
5
|
-
* SSRF, exfiltration and ReDoS test cases. The provider refused every turn
|
|
6
|
-
* ("flagged for possible cybersecurity risk"). The run loop classed that as a
|
|
7
|
-
* transient runtime failure, so it never acked and re-sent the same content on
|
|
8
|
-
* backoff, five refusals in 52 minutes on a login every codex seat shares. The
|
|
9
|
-
* seat handled nothing else until the kernel's requeue cap retired the event.
|
|
10
|
-
*
|
|
11
|
-
* A content refusal is deterministic: the same content is refused again. So it
|
|
12
|
-
* is acked once, as refused, and never retried. A refused request addressed to
|
|
13
|
-
* the seat is not dropped silently either: the wrapper, not the model, posts one
|
|
14
|
-
* line naming the request (PR, head, message id, sender) and, when the seat is
|
|
15
|
-
* run with `--refusal-handoff <agent>`, @mentions that agent to take it. The
|
|
16
|
-
* line repeats nothing else from the request: the flagged text stays out of
|
|
17
|
-
* the pod.
|
|
18
|
-
*
|
|
19
|
-
* Two later rules (2026-11-05, ADR-018 D9.2). A turn that carried unread message
|
|
20
|
-
* bodies reruns once without them before any item is refused, since ambient text
|
|
21
|
-
* may have drawn the refusal. And a refusal drops the pod's session: both CLIs
|
|
22
|
-
* keep the refused prompt in their thread and would resend it on every resume.
|
|
23
|
-
*/
|
|
24
|
-
import { classifySpawnFailure, SPAWN_FAILURE_CLASS } from './spawn-retry.js';
|
|
25
|
-
|
|
26
|
-
/** The ack reason, so the event ledger counts refusals by name. */
|
|
27
|
-
export const CONTENT_REFUSED_REASON = 'provider-refused';
|
|
28
|
-
|
|
29
|
-
export const isContentRefusal = (error) => (
|
|
30
|
-
classifySpawnFailure(error) === SPAWN_FAILURE_CLASS.CONTENT_REFUSED
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The turn result for a refused item. `refused` releases its claim as
|
|
35
|
-
* `upstream-refused` (claim-outcome.js): on a human broadcast the kernel hands
|
|
36
|
-
* the message to one remaining listener, as it does for any upstream refusal.
|
|
37
|
-
* `outcome: 'error'` is terminal on the kernel side; the event is acked, not
|
|
38
|
-
* requeued.
|
|
39
|
-
*/
|
|
40
|
-
export const contentRefusedResult = () => ({
|
|
41
|
-
outcome: 'error',
|
|
42
|
-
refused: { reason: 'upstream-refused' },
|
|
43
|
-
reason: CONTENT_REFUSED_REASON,
|
|
44
|
-
details: { failureClass: SPAWN_FAILURE_CLASS.CONTENT_REFUSED },
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
// A full 40-hex commit id. Shorter hex runs are not taken: the server's pod
|
|
48
|
-
// context frame carries 24-hex pod ids, and a short match would name one as
|
|
49
|
-
// the head.
|
|
50
|
-
const FULL_SHA_RE = /\b[0-9a-f]{40}\b/g;
|
|
51
|
-
const PR_REF_RE = /#\d{2,6}\b/;
|
|
52
|
-
const MAX_HEADS = 3;
|
|
53
|
-
|
|
54
|
-
/** An agent handle as the server resolves mentions; anything else is dropped. */
|
|
55
|
-
export const sanitizeHandoffAgent = (value) => {
|
|
56
|
-
const name = String(value ?? '').trim().replace(/^@/, '');
|
|
57
|
-
return /^[a-z0-9][a-z0-9._-]{0,63}$/i.test(name) ? name : null;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* What the notice may name about a refused request: identifiers only. The PR is
|
|
62
|
-
* the first `#N`; the heads are every distinct full commit id (a request that
|
|
63
|
-
* names two is reported with both, rather than guessing which one is current).
|
|
64
|
-
*/
|
|
65
|
-
export const refusedItemRef = (event) => {
|
|
66
|
-
const payload = event?.payload || {};
|
|
67
|
-
const content = String(payload.content || '');
|
|
68
|
-
const pr = content.match(PR_REF_RE)?.[0] || null;
|
|
69
|
-
const heads = [...new Set(content.match(FULL_SHA_RE) || [])].slice(0, MAX_HEADS);
|
|
70
|
-
const rawMessageId = String(payload.messageId ?? '');
|
|
71
|
-
const messageId = /^[A-Za-z0-9]{1,40}$/.test(rawMessageId) ? rawMessageId : null;
|
|
72
|
-
const from = String(payload.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 40) || null;
|
|
73
|
-
return { pr, heads, messageId, from };
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const describeItem = ({ pr, heads, messageId, from }) => {
|
|
77
|
-
const subject = pr ? `${pr}${heads.length ? ` at ${heads.map((head) => `\`${head}\``).join(' or ')}` : ' (no head named)'}` : 'a request';
|
|
78
|
-
const source = [messageId ? `message ${messageId}` : null, from ? `from ${from}` : null]
|
|
79
|
-
.filter(Boolean).join(' ');
|
|
80
|
-
return source ? `${subject} (${source})` : subject;
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The one line the wrapper posts. `mention` is false in a DM, where @mentioning
|
|
85
|
-
* a third agent would pull it toward a 1:1 conversation it cannot join.
|
|
86
|
-
*/
|
|
87
|
-
export const composeRefusalHandoff = ({ items, handoff = null, mention = true }) => {
|
|
88
|
-
const agent = sanitizeHandoffAgent(handoff);
|
|
89
|
-
const one = items.length === 1;
|
|
90
|
-
const what = one ? describeItem(items[0]) : `${items.length} requests: ${items.map(describeItem).join('; ')}`;
|
|
91
|
-
const lead = `The model provider refused the content of ${what}, so I could not read ${one ? 'it' : 'them'} and will not retry ${one ? 'it' : 'them'}.`;
|
|
92
|
-
if (agent && mention) return `${lead} @${agent}, please take ${one ? 'it' : 'them'}.`;
|
|
93
|
-
return `${lead} ${one ? 'It needs' : 'They need'} another reader.`;
|
|
94
|
-
};
|