@awebai/oats 0.41.1 → 0.42.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/bin/oats.mjs +3 -19
- package/docs/capabilities.md +73 -2
- package/docs/capability-manifest.schema.json +38 -0
- package/docs/desktop-cli-api.md +89 -6
- package/docs/desktop.md +15 -5
- package/docs/execution-targets.md +202 -45
- package/docs/implementation.md +3 -1
- package/docs/release-notes/v0.42.0.md +398 -0
- package/docs/souls-and-instances.md +315 -2
- package/lib/capability-contract.mjs +56 -0
- package/lib/core.mjs +1214 -264
- package/lib/instance-lifecycle.mjs +12 -1
- package/lib/launch-preference.mjs +9 -0
- package/lib/login-environment.mjs +212 -0
- package/lib/retire-output.mjs +50 -0
- package/lib/tree-copy.mjs +4 -2
- package/package.json +1 -1
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { basename, join } from "node:path";
|
|
13
|
-
import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, stopInstanceSession } from "./core.mjs";
|
|
13
|
+
import { findInstanceHomes, inspectInstanceSession, listAgents, listInstances, observeSessionWithoutReceipt, retirePendingMarkerPath, retirementRecoveryFacts, stopInstanceSession } from "./core.mjs";
|
|
14
|
+
import { groupedByOwner } from "./retire-output.mjs";
|
|
14
15
|
import { observeInstanceGit } from "./instance-git.mjs";
|
|
15
16
|
import { appendEvent } from "./instance-events.mjs";
|
|
16
17
|
import { oatsError } from "./errors.mjs";
|
|
@@ -164,6 +165,8 @@ function writeFileSyncAtomic(path, value) {
|
|
|
164
165
|
writeFileSync(tmp, JSON.stringify(value, null, 2)); renameSync(tmp, path);
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
/** How many declared roots a retire plan's recovery note lists before `, and N more`. */
|
|
169
|
+
const PLAN_NOT_COPIED_CAP = 16;
|
|
167
170
|
/** Facts for Remove (retire): what retirement would touch, with the design's
|
|
168
171
|
* defaults (retain worktree and branch; never touch a PR). `oats retire`
|
|
169
172
|
* itself applies them: plain retire re-homes the worktree; --discard-worktree
|
|
@@ -181,6 +184,12 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
181
184
|
pullRequest: "unknown" /* forge facts are the ADE's (P1); the kernel never claims 'no PR' */ };
|
|
182
185
|
const defaults = { retainWorktree: meta.work === "worktree", deleteBranch: false, stopChildren: true, retainChildren: true };
|
|
183
186
|
const safety = [me.home, target.session.state, target.launched, facts.work.observed ? [facts.work.revision, facts.work.branch, facts.work.changed, facts.work.untracked] : null, kids.map((c) => c.home)];
|
|
187
|
+
// What retire would preserve and where, read from the spawn baseline and the
|
|
188
|
+
// work mode: nothing is hashed, and the plan revision does not depend on it.
|
|
189
|
+
// The declared roots are listed as written, capped so one note stays short.
|
|
190
|
+
const recovery = retirementRecoveryFacts(me.home);
|
|
191
|
+
const declared = recovery.disposableHome.slice(0, PLAN_NOT_COPIED_CAP);
|
|
192
|
+
const notCopied = declared.length ? `; not copied: ${groupedByOwner(declared, "root")}${recovery.disposableHome.length > declared.length ? `, and ${recovery.disposableHome.length - declared.length} more` : ""}` : "";
|
|
184
193
|
appendEvent(me.home, { kind: "retire-planned", data: { planRevision: planRevision(safety), children: kids.length, dirty: facts.work.observed ? facts.work.changed + facts.work.untracked : null } }, { workspaceOnly: true });
|
|
185
194
|
return { lifecycleApi: LIFECYCLE_API, action: "retire", instance: name, home: me.home, at: new Date().toISOString(), facts, defaults, planRevision: planRevision(safety),
|
|
186
195
|
notes: [
|
|
@@ -190,5 +199,7 @@ export function planRetire(ctx, root, name, { home } = {}) {
|
|
|
190
199
|
...((kids.ambiguous || []).length ? [`${kids.ambiguous.length} instance(s) record this name as parent but the name is not unique under this root; they are listed under ambiguous and NOT acted on`] : []),
|
|
191
200
|
...(target.session.note ? [target.session.established ? `session absent: ${target.session.note}; nothing needs quiescing` : `session not observably absent: ${target.session.note}; retire refuses until it is stopped`] : []),
|
|
192
201
|
"pull request state is unknown to the kernel; the ADE reports it when a forge connection exists",
|
|
202
|
+
`recovery: the home is copied to ${recovery.recoveryRoot} before the home is removed, when it changed since spawn${notCopied}`,
|
|
203
|
+
...(meta.work === "worktree" ? ["recovery: uncommitted worktree state is copied there too"] : meta.work === "directory" ? ["recovery: work/ is copied there when it is not empty"] : []),
|
|
193
204
|
] };
|
|
194
205
|
}
|
|
@@ -78,6 +78,15 @@ export function harnessUnavailable({ harness, from, at, why }) {
|
|
|
78
78
|
return fail("E_HARNESS_UNAVAILABLE", `${harness} is not installed on this machine (${why}); ${source} chose it — ${fix}`, { harness, from, at: at ?? null, fix });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** What a person can do about a harness its pane would not find. */
|
|
82
|
+
export const PANE_PATH_FIX = "declare the executable's absolute path in the launch configuration (executable), set PATH in the launch configuration (env), or start the OATS tmux server from your own shell (tmux -L oats new-session -d -s <session> -n hq), so that it has your PATH";
|
|
83
|
+
/** E_HARNESS_UNAVAILABLE for a harness looked up where its pane looks it up (the session's or the
|
|
84
|
+
* server's PATH, or a launch configuration's): `why` names that source and what went wrong, never
|
|
85
|
+
* the PATH's contents. Never a fallback to this process's PATH or to another harness. */
|
|
86
|
+
export function harnessNotOnPanePath({ harness, from, at, why }) {
|
|
87
|
+
return fail("E_HARNESS_UNAVAILABLE", `${harness} is not available to its pane: ${why}, and a pane looks its harness up there; ${PANE_PATH_FIX}`, { harness, from, at: at ?? null, fix: PANE_PATH_FIX });
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
/** The closed `Launch` report: `{declared, effective: {harness, model, launchConfig}, from, at, problem}`. */
|
|
82
91
|
export function launchReport({ declared = null, harness, model = null, launchConfig = null, from, at = null, problem = null }) {
|
|
83
92
|
return { declared: declared ? { harness: declared.harness, model: declared.model ?? null } : null, effective: { harness, model: model || null, launchConfig: launchConfig || null }, from, at: at ?? null, problem };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The user's login environment, read when the kernel creates the OATS tmux server (awebai/oats#616).
|
|
2
|
+
//
|
|
3
|
+
// A tmux server keeps the environment of the process that starts it, and every pane on it inherits
|
|
4
|
+
// that. When the kernel starts the server it gives it what the user's own login shell sets up (the
|
|
5
|
+
// PATH a version manager activates, an agent socket an rc file exports), read as data, never the
|
|
6
|
+
// creator's ambient environment: the creator may be an agent instance, whose identity, credentials
|
|
7
|
+
// and harness variables must not become the server's. docs/execution-targets.md owns the rule.
|
|
8
|
+
//
|
|
9
|
+
// Whose: only a creator whose HOME is the user's home directory in the password database is taken
|
|
10
|
+
// to run in the user's session, and only for it is the login shell run. A process that redirected
|
|
11
|
+
// HOME (a sandbox, a test fixture) is not: its reading fails, and its caller takes the fallback. The
|
|
12
|
+
// test seam (a fake shell) is honoured only there, never in the user's own session.
|
|
13
|
+
//
|
|
14
|
+
// How: the login shell from the password database (os.userInfo().shell, never the creator's SHELL),
|
|
15
|
+
// one of bash, zsh or fish, runs `-l -i -c '<node> -e <emitter> <nonce>'`, started from a fixed seed
|
|
16
|
+
// in the OS user's home directory. The emitter writes JSON.stringify(process.env) to its stdout as
|
|
17
|
+
// one frame: a start and an end delimiter that each carry a random nonce, made for this reading only,
|
|
18
|
+
// and begin and end with a line break, which valid JSON never holds unescaped. Exactly one complete,
|
|
19
|
+
// ordered frame for that nonce is the answer; whatever the shell's start-up prints around it (rc
|
|
20
|
+
// chatter, prompts, text shaped like assignments or like JSON, a frame with another nonce) is
|
|
21
|
+
// discarded, and its stderr is not read. The nonce separates the answer from accidental chatter; it
|
|
22
|
+
// does not authenticate anything against the user's own start-up files, which can also redirect the
|
|
23
|
+
// shell's stdout away (then there is no answer). Not a descriptor of its own: bash 5.3, started -l -i, marks
|
|
24
|
+
// the descriptors it inherits from 3 to 19 close-on-exec, so a pipe there never reaches the emitter,
|
|
25
|
+
// and no shell is relied on to leave any descriptor but stdout to the command it runs. No value goes
|
|
26
|
+
// into argv (the nonce is not one), a file, a log or a diagnostic. The answer is accepted only whole: exit status 0, at most
|
|
27
|
+
// 1 MiB, one JSON object whose values are all strings, plain identifiers as names, HOME and PATH
|
|
28
|
+
// present. The functions bash exports (`BASH_FUNC_<name>%%`, a stock profile's `export -f`) are code,
|
|
29
|
+
// not environment: they are dropped from the answer, never carried. Nothing in it is evaluated.
|
|
30
|
+
//
|
|
31
|
+
// Bounded: the shell runs in its own process group. The deadline (5 s) ends the read even when a
|
|
32
|
+
// descendant still holds the shell's stdout after the shell exited; then that group, and only that
|
|
33
|
+
// group, gets SIGKILL (never the shell's own pid, which is reaped by then). A descendant that made
|
|
34
|
+
// its own session or group (a daemon) is outside it, and group cleanup does not promise to end it.
|
|
35
|
+
//
|
|
36
|
+
// Imports nothing but node's own modules: the kernel stays dependency-free.
|
|
37
|
+
import { spawnSync } from "node:child_process";
|
|
38
|
+
import { randomBytes } from "node:crypto";
|
|
39
|
+
import { accessSync, constants as fsConstants, realpathSync } from "node:fs";
|
|
40
|
+
import { userInfo } from "node:os";
|
|
41
|
+
import { basename, resolve } from "node:path";
|
|
42
|
+
import { signalGroup } from "./process-group.mjs";
|
|
43
|
+
|
|
44
|
+
/** The session variables the seed carries: what lets the user's setup reach their agent socket and
|
|
45
|
+
* display. Per name, the first source where the name is present (an empty value counts) is used. */
|
|
46
|
+
export const LOGIN_SESSION_ENV = ["SSH_AUTH_SOCK", "DISPLAY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"];
|
|
47
|
+
/** The shells whose `-l -i -c` and single quotes this relies on. Any other is an acquisition failure. */
|
|
48
|
+
export const LOGIN_SHELLS = ["bash", "zsh", "fish"];
|
|
49
|
+
export const LOGIN_CAPTURE_TIMEOUT_MS = 5000;
|
|
50
|
+
export const LOGIN_CAPTURE_MAX_BYTES = 1024 * 1024;
|
|
51
|
+
const SEED_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
52
|
+
const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
53
|
+
const PLATFORM_READ_TIMEOUT_MS = 2000;
|
|
54
|
+
// The frame's delimiters, each `\n<marker><nonce>\n`; the emitter receives the nonce as its one
|
|
55
|
+
// argument (process.argv[1]).
|
|
56
|
+
const BEGIN = "OATS-LOGIN-ENVIRONMENT-BEGIN-", END = "OATS-LOGIN-ENVIRONMENT-END-";
|
|
57
|
+
// Loops until every byte is written: stdout is a blocking pipe in the emitter.
|
|
58
|
+
const EMITTER = `const f=require("fs"),n=process.argv[1],b=Buffer.from("\\n${BEGIN}"+n+"\\n"+JSON.stringify(process.env)+"\\n${END}"+n+"\\n");for(let o=0;o<b.length;)o+=f.writeSync(1,b,o)`;
|
|
59
|
+
const BASH_FUNCTION = /^BASH_FUNC_.+%%$/;
|
|
60
|
+
/** One single-quoted word for `shell`: POSIX's `'…'\\''…'` for bash and zsh; fish's own form, where a
|
|
61
|
+
* backslash and a quote inside single quotes are escaped with a backslash. */
|
|
62
|
+
const quoteFor = (shell) => basename(shell) === "fish"
|
|
63
|
+
? (s) => `'${String(s).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
|
|
64
|
+
: (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
65
|
+
const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the login environment. `creatorEnv`: the creating process's environment (its locale names,
|
|
69
|
+
* and its session variables when it is not an instance). `instance`: whether the creator is an
|
|
70
|
+
* instance; then `recorded` is the global environment of the server its home records, as the strict
|
|
71
|
+
* reader read it (null when it could not be read). `shell` replaces the password database's shell, a
|
|
72
|
+
* test seam honoured only for a creator outside the user's session (its HOME is not the password
|
|
73
|
+
* database's), where it also makes the platform's tools the creator's (its PATH's). → `{ env, notes }` (env as the shell's environment ended up, unfiltered) or
|
|
74
|
+
* `{ failure, notes }`: why, in words that hold no value. `notes`: what a person should know even on
|
|
75
|
+
* success (a platform session that could not be read), also without values; a caller reports them
|
|
76
|
+
* only with a successful capture, so a failure is one line.
|
|
77
|
+
*/
|
|
78
|
+
export function captureLoginEnvironment({ creatorEnv = process.env, instance = false, recorded = null, shell: seam, timeoutMs = LOGIN_CAPTURE_TIMEOUT_MS } = {}) {
|
|
79
|
+
const notes = [];
|
|
80
|
+
let user;
|
|
81
|
+
try { user = userInfo(); } catch { return { failure: "your user has no password database entry", notes }; }
|
|
82
|
+
// Only a creator in the user's session (its HOME is the user's home directory) has a login shell run
|
|
83
|
+
// for it; a test seam is never honoured there.
|
|
84
|
+
const ownSession = typeof creatorEnv.HOME === "string" && creatorEnv.HOME !== "" && samePath(creatorEnv.HOME, user.homedir);
|
|
85
|
+
if (!ownSession && !seam) return { failure: "this process's HOME is not your home directory, so it does not run in your login session", notes };
|
|
86
|
+
const shell = ownSession ? user.shell : seam;
|
|
87
|
+
if (!shell) return { failure: "your user has no login shell in the password database", notes };
|
|
88
|
+
if (!LOGIN_SHELLS.includes(basename(shell))) return { failure: `your login shell (${basename(shell)}) is not bash, zsh or fish`, notes };
|
|
89
|
+
// Nothing is read for a shell that cannot run (the user session included).
|
|
90
|
+
try { accessSync(shell, fsConstants.X_OK); } catch (e) { return { failure: `your login shell could not be started (${e.code || "error"})`, notes }; }
|
|
91
|
+
const seed = { HOME: user.homedir, USER: user.username, LOGNAME: user.username, SHELL: user.shell ?? shell, PATH: SEED_PATH, TERM: "dumb" };
|
|
92
|
+
for (const name of ["LANG", "LC_ALL", "LC_CTYPE"]) if (creatorEnv[name] !== undefined) seed[name] = creatorEnv[name];
|
|
93
|
+
Object.assign(seed, sessionVariables({ creatorEnv, instance, recorded, notes, user, toolPath: ownSession ? SEED_PATH : creatorEnv.PATH }));
|
|
94
|
+
const sq = quoteFor(shell);
|
|
95
|
+
const nonce = randomBytes(16).toString("hex");
|
|
96
|
+
const r = spawnSync(shell, ["-l", "-i", "-c", `${sq(process.execPath)} -e ${sq(EMITTER)} ${nonce}`], {
|
|
97
|
+
cwd: user.homedir, env: seed, stdio: ["ignore", "pipe", "ignore"],
|
|
98
|
+
timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: LOGIN_CAPTURE_MAX_BYTES, detached: true, windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
// The deadline or the size limit ended it: whatever of its group is left (a descendant holding the
|
|
101
|
+
// shell's stdout, a shell still in its rc) is killed. Only this group, only then: the shell itself
|
|
102
|
+
// was reaped before spawnSync returned, so its bare pid is never signalled.
|
|
103
|
+
if (r.error?.code === "ETIMEDOUT" || r.error?.code === "ENOBUFS" || r.signal) signalGroup(r, "SIGKILL");
|
|
104
|
+
if (r.error?.code === "ETIMEDOUT") return { failure: `your login shell did not answer within ${Math.round(timeoutMs / 1000)} s`, notes };
|
|
105
|
+
if (r.error?.code === "ENOBUFS") return { failure: "its answer was larger than 1 MiB", notes };
|
|
106
|
+
if (r.error) return { failure: `your login shell could not be started (${r.error.code || "error"})`, notes };
|
|
107
|
+
if (r.signal) return { failure: `your login shell was ended by ${r.signal}`, notes };
|
|
108
|
+
if (r.status !== 0) return { failure: `your login shell exited with status ${r.status}`, notes };
|
|
109
|
+
const printed = r.stdout;
|
|
110
|
+
if (printed?.length > LOGIN_CAPTURE_MAX_BYTES) return { failure: "its answer was larger than 1 MiB", notes };
|
|
111
|
+
// Exactly one start and one end delimiter of this nonce, in that order; anything else is no answer
|
|
112
|
+
// or an ambiguous one, never a part of it taken as the whole.
|
|
113
|
+
const begin = Buffer.from(`\n${BEGIN}${nonce}\n`), end = Buffer.from(`\n${END}${nonce}\n`);
|
|
114
|
+
const from = printed ? printed.indexOf(begin) : -1, to = printed ? printed.indexOf(end) : -1;
|
|
115
|
+
if (from < 0 && to < 0) return { failure: "your login shell gave no answer", notes };
|
|
116
|
+
if (from < 0 || to < from + begin.length || printed.indexOf(begin, from + 1) >= 0 || printed.indexOf(end, to + 1) >= 0) return { failure: "its answer was not exactly one complete frame", notes };
|
|
117
|
+
const data = printed.subarray(from + begin.length, to);
|
|
118
|
+
let env;
|
|
119
|
+
try { env = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(data)); } catch { return { failure: "its answer was not one complete JSON object", notes }; }
|
|
120
|
+
if (!env || typeof env !== "object" || Array.isArray(env)) return { failure: "its answer was not one complete JSON object", notes };
|
|
121
|
+
// Functions bash exports are code every bash in a pane would import: dropped, never carried.
|
|
122
|
+
for (const name of Object.keys(env)) if (BASH_FUNCTION.test(name)) delete env[name];
|
|
123
|
+
const names = Object.keys(env);
|
|
124
|
+
if (names.some((name) => typeof env[name] !== "string")) return { failure: "its answer held a value that is not text", notes };
|
|
125
|
+
if (names.some((name) => !NAME.test(name))) return { failure: "its answer held a name that is not a plain identifier", notes };
|
|
126
|
+
for (const name of ["HOME", "PATH"]) if (!Object.hasOwn(env, name)) return { failure: `its answer had no ${name}`, notes };
|
|
127
|
+
return { env: Object.assign(Object.create(null), env), notes };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The seed's session variables, per name from the first source that has it: (a) the creator, only
|
|
131
|
+
* when it is not an instance; (b) for an instance, the recorded server's global environment; (c)
|
|
132
|
+
* the platform's user session, read as data. */
|
|
133
|
+
function sessionVariables({ creatorEnv, instance, recorded, notes, user, toolPath }) {
|
|
134
|
+
const out = {}, missing = [];
|
|
135
|
+
for (const name of LOGIN_SESSION_ENV) {
|
|
136
|
+
if (!instance && creatorEnv[name] !== undefined) out[name] = creatorEnv[name];
|
|
137
|
+
else if (instance && recorded && Object.hasOwn(recorded, name)) out[name] = recorded[name];
|
|
138
|
+
else missing.push(name);
|
|
139
|
+
}
|
|
140
|
+
if (!missing.length) return out;
|
|
141
|
+
const platform = platformSession(missing, user, toolPath);
|
|
142
|
+
if (platform.failure) notes.push(`could not read the user session's environment (${platform.failure}); ${missing.join(", ")} came only from your login shell`);
|
|
143
|
+
for (const name of missing) if (Object.hasOwn(platform.env, name)) out[name] = platform.env[name];
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The user session's variables of `names`: `systemctl --user show-environment` on Linux,
|
|
148
|
+
* `launchctl getenv NAME` on macOS, each bounded, never through a shell. The tool is looked up on
|
|
149
|
+
* `toolPath` (the seed's PATH; under the test seam, the creator's) and runs with an environment of
|
|
150
|
+
* the OS user's own (HOME, USER, LOGNAME and, on Linux, the user's runtime directory, where
|
|
151
|
+
* systemctl finds the user bus), never the creator's. A failed or unavailable read, a timeout or a
|
|
152
|
+
* missing tool leave every name absent. → `{ env, failure? }` */
|
|
153
|
+
function platformSession(names, user, toolPath = SEED_PATH) {
|
|
154
|
+
const toolEnv = { HOME: user.homedir, USER: user.username, LOGNAME: user.username, PATH: toolPath ?? SEED_PATH, ...(process.platform === "linux" && user.uid >= 0 ? { XDG_RUNTIME_DIR: `/run/user/${user.uid}` } : {}) };
|
|
155
|
+
const run = (file, args) => spawnSync(file, args, { env: toolEnv, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: PLATFORM_READ_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: LOGIN_CAPTURE_MAX_BYTES, windowsHide: true });
|
|
156
|
+
const why = (r, tool) => r.error ? (r.error.code === "ENOENT" ? `${tool} was not found` : r.error.code === "ETIMEDOUT" ? `${tool} timed out` : `${tool} failed (${r.error.code})`) : r.status !== 0 ? `${tool} exited with status ${r.status ?? r.signal}` : null;
|
|
157
|
+
if (process.platform === "linux") {
|
|
158
|
+
const r = run("systemctl", ["--user", "show-environment"]);
|
|
159
|
+
const failure = why(r, "systemctl --user show-environment");
|
|
160
|
+
return failure ? { env: {}, failure } : { env: parseSystemdEnvironment(r.stdout, names) };
|
|
161
|
+
}
|
|
162
|
+
if (process.platform === "darwin") {
|
|
163
|
+
const env = {};
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const r = run("launchctl", ["getenv", name]);
|
|
166
|
+
const failure = why(r, "launchctl getenv");
|
|
167
|
+
if (failure) return { env, failure };
|
|
168
|
+
const value = r.stdout.replace(/\n$/, "");
|
|
169
|
+
if (value !== "") env[name] = value; // empty output: absent
|
|
170
|
+
}
|
|
171
|
+
return { env };
|
|
172
|
+
}
|
|
173
|
+
return { env: {} };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `systemctl --user show-environment` read as data: lines `NAME=value`. A value systemd printed in
|
|
178
|
+
* its `$'…'` C-escaped form (\a \b \e \f \n \r \t \v \\ \' \" \ooo \xHH) is decoded, as bytes, then as
|
|
179
|
+
* UTF-8; one that cannot be decoded leaves its name absent. Only the names asked for are kept.
|
|
180
|
+
*/
|
|
181
|
+
export function parseSystemdEnvironment(text, names = LOGIN_SESSION_ENV) {
|
|
182
|
+
const env = {};
|
|
183
|
+
for (const line of String(text).split("\n")) {
|
|
184
|
+
const eq = line.indexOf("=");
|
|
185
|
+
if (eq <= 0) continue;
|
|
186
|
+
const name = line.slice(0, eq), raw = line.slice(eq + 1);
|
|
187
|
+
if (!names.includes(name)) continue;
|
|
188
|
+
if (!raw.startsWith("$'")) { env[name] = raw; continue; }
|
|
189
|
+
const value = decodeCEscaped(raw);
|
|
190
|
+
if (value !== null) env[name] = value;
|
|
191
|
+
}
|
|
192
|
+
return env;
|
|
193
|
+
}
|
|
194
|
+
const SIMPLE_ESCAPES = { a: 7, b: 8, e: 27, f: 12, n: 10, r: 13, t: 9, v: 11, "\\": 92, "'": 39, "\"": 34 };
|
|
195
|
+
function decodeCEscaped(raw) {
|
|
196
|
+
if (!raw.endsWith("'") || raw.length < 3) return null;
|
|
197
|
+
const body = raw.slice(2, -1);
|
|
198
|
+
const bytes = [];
|
|
199
|
+
for (let i = 0; i < body.length; i++) {
|
|
200
|
+
const c = String.fromCodePoint(body.codePointAt(i));
|
|
201
|
+
if (c === "'") return null; // an unescaped quote cannot be inside
|
|
202
|
+
if (c !== "\\") { bytes.push(...Buffer.from(c, "utf8")); i += c.length - 1; continue; }
|
|
203
|
+
const next = body[i + 1];
|
|
204
|
+
if (next !== undefined && Object.hasOwn(SIMPLE_ESCAPES, next)) { bytes.push(SIMPLE_ESCAPES[next]); i += 1; continue; }
|
|
205
|
+
const oct = /^[0-7]{3}/.exec(body.slice(i + 1, i + 4));
|
|
206
|
+
if (oct && parseInt(oct[0], 8) < 256) { bytes.push(parseInt(oct[0], 8)); i += 3; continue; }
|
|
207
|
+
const hex = /^x([0-9A-Fa-f]{2})/.exec(body.slice(i + 1, i + 4));
|
|
208
|
+
if (hex) { bytes.push(parseInt(hex[1], 16)); i += 3; continue; }
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
try { return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)); } catch { return null; }
|
|
212
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** What `oats retire` says about the work it preserved. One function renders
|
|
2
|
+
* the lines for the local and the remote path of bin/oats.mjs, from the
|
|
3
|
+
* receipt's `workRecovery` (lib/core.mjs, retireInstance). A receipt from an
|
|
4
|
+
* older kernel, or a stored one, may carry `workRecoveries[]` instead: one
|
|
5
|
+
* block is printed per entry. Dependency-free. */
|
|
6
|
+
|
|
7
|
+
export const formatBytes = (n) => n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KiB` : n < 1024 ** 3 ? `${(n / 1024 ** 2).toFixed(1)} MiB` : `${(n / 1024 ** 3).toFixed(1)} GiB`;
|
|
8
|
+
|
|
9
|
+
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
10
|
+
|
|
11
|
+
/** `a, b (owner); c (other)`: the `key` of each row, grouped by its owner; owners sorted, rows in their order. */
|
|
12
|
+
export function groupedByOwner(rows, key) {
|
|
13
|
+
const groups = new Map();
|
|
14
|
+
for (const row of rows) groups.set(row.owner, [...(groups.get(row.owner) || []), row[key]]);
|
|
15
|
+
return [...groups].sort(([a], [b]) => byCodeUnit(String(a), String(b))).map(([owner, names]) => `${names.join(", ")}${owner ? ` (${owner})` : ""}`).join("; ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** `{ paths: [{ path, bytes }], bytes }` as one line: the first 8 with their size, then the total. */
|
|
19
|
+
function sizedPathsLine(label, part) {
|
|
20
|
+
if (!part?.paths?.length) return [];
|
|
21
|
+
const shown = part.paths.slice(0, 8).map((p) => `${p.path} (${formatBytes(p.bytes)})`);
|
|
22
|
+
const more = part.paths.length > 8 ? `, and ${part.paths.length - 8} more` : "";
|
|
23
|
+
return [` ${label}: ${shown.join(", ")}${more} — ${formatBytes(part.bytes)} in total`];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The recoveries a retire receipt names: every entry of a historical `workRecoveries[]`, else its one `workRecovery`. */
|
|
27
|
+
export function receiptRecoveries(receipt) {
|
|
28
|
+
if (Array.isArray(receipt?.workRecoveries) && receipt.workRecoveries.length) return receipt.workRecoveries;
|
|
29
|
+
return receipt?.workRecovery ? [receipt.workRecovery] : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The lines `oats retire` prints for what a receipt preserved; `host` (the
|
|
33
|
+
* remote path) names where. Per recovery: the classes, the path with its
|
|
34
|
+
* size, then, when they apply, what was copied from the home, the copied
|
|
35
|
+
* outputs, the home entries left out by a capability's declaration (names and
|
|
36
|
+
* owners only) and which parts were copied again under after-hooks/. */
|
|
37
|
+
export function workRecoveryLines(receipt, { host } = {}) {
|
|
38
|
+
const lines = [];
|
|
39
|
+
for (const recovery of receiptRecoveries(receipt)) {
|
|
40
|
+
lines.push(`Work that was not committed has been preserved${host ? ` on ${host}` : ""}: ${(recovery.classes || []).join(", ")}`);
|
|
41
|
+
lines.push(` ${recovery.path}${typeof recovery.bytes === "number" ? ` (${formatBytes(recovery.bytes)})` : ""}`);
|
|
42
|
+
lines.push(...sizedPathsLine("copied from the home", recovery.home));
|
|
43
|
+
lines.push(...sizedPathsLine("copied outputs", recovery.outputs));
|
|
44
|
+
const notCopied = Array.isArray(recovery.notCopied) ? recovery.notCopied.filter((row) => typeof row?.path === "string") : [];
|
|
45
|
+
if (notCopied.length) lines.push(` not copied: ${groupedByOwner(notCopied, "path")}`);
|
|
46
|
+
const again = [recovery.afterHooks?.home === true && "home", recovery.afterHooks?.work === true && "work"].filter(Boolean);
|
|
47
|
+
if (again.length) lines.push(` after the retire hooks: ${again.join(" and ")} copied again under after-hooks/`);
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
package/lib/tree-copy.mjs
CHANGED
|
@@ -21,14 +21,16 @@ import { oatsError } from "./errors.mjs";
|
|
|
21
21
|
* - deterministic traversal (sorted entries), so two copies of one tree hash
|
|
22
22
|
* identically;
|
|
23
23
|
* - symlinks are recreated VERBATIM — never followed, never rewritten — because
|
|
24
|
-
* the bytes about to be hashed must be the bytes the author wrote
|
|
24
|
+
* the bytes about to be hashed must be the bytes the author wrote. The target
|
|
25
|
+
* is read and written as bytes: read as text, a target that is not valid
|
|
26
|
+
* UTF-8 would be recreated as another one;
|
|
25
27
|
* - FIFOs, sockets and device nodes are rejected fail-closed: they are not
|
|
26
28
|
* distributable content, and copying them has no defined meaning here;
|
|
27
29
|
* - directory modes are applied AFTER their children, so a read-only source
|
|
28
30
|
* directory cannot block writing its own contents. */
|
|
29
31
|
export function copyTreeSafe(src, dest) {
|
|
30
32
|
const st = lstatSync(src);
|
|
31
|
-
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src), dest); return; }
|
|
33
|
+
if (st.isSymbolicLink()) { symlinkSync(readlinkSync(src, "buffer"), dest); return; }
|
|
32
34
|
if (st.isFile()) { copyFileSync(src, dest); chmodSync(dest, st.mode & 0o7777); return; }
|
|
33
35
|
if (!st.isDirectory()) {
|
|
34
36
|
throw oatsError("invalid-source", `${src} is not a regular file, directory or symlink (${st.isFIFO() ? "FIFO" : st.isSocket() ? "socket" : st.isBlockDevice() || st.isCharacterDevice() ? "device node" : "unsupported file type"}) — package and capability trees carry distributable content only`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|