@phnx-labs/agents-cli 1.22.78 → 1.22.80
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/CHANGELOG.md +15 -0
- package/README.md +28 -1
- package/dist/bootstrap.js +40 -12
- package/dist/commands/accounts.d.ts +22 -0
- package/dist/commands/accounts.js +158 -35
- package/dist/commands/config.js +37 -0
- package/dist/commands/exec.js +21 -10
- package/dist/commands/update.js +169 -18
- package/dist/commands/versions.d.ts +11 -0
- package/dist/commands/versions.js +30 -4
- package/dist/commands/view.d.ts +69 -3
- package/dist/commands/view.js +235 -75
- package/dist/index.js +35 -2
- package/dist/lib/account-catalog.d.ts +97 -1
- package/dist/lib/account-catalog.js +134 -6
- package/dist/lib/account-registry.d.ts +43 -0
- package/dist/lib/account-registry.js +97 -2
- package/dist/lib/accounting/rotate.d.ts +2 -2
- package/dist/lib/accounting/rotate.js +5 -5
- package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
- package/dist/lib/accounts/auth-operation-lock.js +55 -0
- package/dist/lib/accounts/connect.d.ts +170 -0
- package/dist/lib/accounts/connect.js +383 -0
- package/dist/lib/capabilities.js +2 -0
- package/dist/lib/commands.js +2 -0
- package/dist/lib/config-keys.d.ts +11 -2
- package/dist/lib/config-keys.js +21 -1
- package/dist/lib/daemon/daemon.js +5 -0
- package/dist/lib/daemon/harness-update-service.d.ts +110 -0
- package/dist/lib/daemon/harness-update-service.js +216 -0
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +5 -0
- package/dist/lib/device-config.d.ts +0 -1
- package/dist/lib/device-config.js +50 -5
- package/dist/lib/exec.js +26 -2
- package/dist/lib/fs-atomic.d.ts +2 -0
- package/dist/lib/fs-atomic.js +2 -0
- package/dist/lib/hooks/install.js +7 -2
- package/dist/lib/installations/active-check.d.ts +48 -0
- package/dist/lib/installations/active-check.js +84 -0
- package/dist/lib/installations/index.d.ts +5 -1
- package/dist/lib/installations/index.js +4 -0
- package/dist/lib/installations/installation-lock.d.ts +6 -0
- package/dist/lib/installations/installation-lock.js +29 -0
- package/dist/lib/installations/launch-gate.d.ts +69 -0
- package/dist/lib/installations/launch-gate.js +133 -0
- package/dist/lib/installations/native-command.d.ts +5 -0
- package/dist/lib/installations/native-command.js +52 -0
- package/dist/lib/installations/shims.d.ts +8 -2
- package/dist/lib/installations/shims.js +105 -2
- package/dist/lib/installations/store.d.ts +5 -1
- package/dist/lib/installations/store.js +24 -3
- package/dist/lib/installations/strategies.js +55 -35
- package/dist/lib/installations/types.d.ts +17 -0
- package/dist/lib/installations/update-cancellation.d.ts +82 -0
- package/dist/lib/installations/update-cancellation.js +122 -0
- package/dist/lib/installations/update-policy.d.ts +69 -0
- package/dist/lib/installations/update-policy.js +114 -0
- package/dist/lib/installations/update-runtime.d.ts +118 -0
- package/dist/lib/installations/update-runtime.js +321 -0
- package/dist/lib/installations/update.d.ts +25 -0
- package/dist/lib/installations/update.js +141 -2
- package/dist/lib/installations/versions.d.ts +1 -0
- package/dist/lib/installations/versions.js +166 -131
- package/dist/lib/platform/process.d.ts +3 -1
- package/dist/lib/platform/process.js +2 -2
- package/dist/lib/staleness/detectors/commands.d.ts +1 -2
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
- package/dist/lib/staleness/detectors/rules.d.ts +1 -2
- package/dist/lib/staleness/detectors/skills.d.ts +1 -2
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
- package/dist/lib/staleness/writers/commands.d.ts +1 -2
- package/dist/lib/staleness/writers/hooks.d.ts +1 -2
- package/dist/lib/staleness/writers/mcp.d.ts +1 -2
- package/dist/lib/staleness/writers/permissions.d.ts +1 -12
- package/dist/lib/staleness/writers/plugins.d.ts +1 -6
- package/dist/lib/staleness/writers/rules.d.ts +1 -2
- package/dist/lib/staleness/writers/skills.d.ts +1 -2
- package/dist/lib/staleness/writers/subagents.d.ts +1 -2
- package/dist/lib/staleness/writers/workflows.d.ts +1 -8
- package/dist/lib/state.d.ts +3 -1
- package/dist/lib/state.js +38 -13
- package/dist/lib/types.d.ts +26 -1
- package/dist/lib/types.js +5 -0
- package/dist/lib/view-types.d.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { nativeAccountCapability, nativeAccountNamingRefusal } from '../account-capabilities.js';
|
|
3
|
+
import { listNativeAccounts } from '../account-registry.js';
|
|
4
|
+
import { readMeta } from '../state.js';
|
|
5
|
+
import { acquireAuthOperationLock } from './auth-operation-lock.js';
|
|
6
|
+
/**
|
|
7
|
+
* `agents accounts connect <harness> [name]` — the stable-account front door
|
|
8
|
+
* (PHNX-3940).
|
|
9
|
+
*
|
|
10
|
+
* An account is stable INDEPENDENT of releases: connecting a NEW account mints a
|
|
11
|
+
* fresh opaque installation label, installs the current release into that
|
|
12
|
+
* label's isolated home (even when the same release is already installed under
|
|
13
|
+
* another label), and launches the harness's own native login there — no
|
|
14
|
+
* credential is copied or refreshed, the OAuth login stays owned by the harness.
|
|
15
|
+
* Reconnecting a NAMED EXISTING account reuses that account's existing home and
|
|
16
|
+
* fails CLOSED if the completed login is a different identity, so a reconnect can
|
|
17
|
+
* never overwrite some other account's home.
|
|
18
|
+
*
|
|
19
|
+
* This module owns the release-independent decision (plan), the opaque label,
|
|
20
|
+
* and the fail-closed identity check. The actual install + login spawn is
|
|
21
|
+
* injected (`ConnectRunners`) so the pure decision path is testable without a
|
|
22
|
+
* network install or a TTY, and so the parent can own the real E2E login+verify.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Ambient provider-credential env vars stripped before a native login spawn, so
|
|
26
|
+
* the OAuth flow authenticates as the human's identity rather than being
|
|
27
|
+
* short-circuited by an injected API key / setup-token that would impersonate a
|
|
28
|
+
* different account into the fresh home.
|
|
29
|
+
*/
|
|
30
|
+
const PROVIDER_AUTH_ENV_KEYS = [
|
|
31
|
+
'ANTHROPIC_API_KEY',
|
|
32
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
33
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
34
|
+
'OPENAI_API_KEY',
|
|
35
|
+
'CODEX_API_KEY',
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Native-login invocation per harness. Only harnesses with a REAL, finite login
|
|
39
|
+
* COMMAND are listed — connect fails clearly for anything else rather than faking
|
|
40
|
+
* a flow that never signs the user in. Verified against the installed CLIs
|
|
41
|
+
* (PHNX-3940): `claude auth login --help` → "Sign in to your Anthropic account"
|
|
42
|
+
* with `--email`; `codex login` drives the OAuth flow. A harness is added here
|
|
43
|
+
* only once its isolated login command is verified.
|
|
44
|
+
*/
|
|
45
|
+
const LOGIN_INVOCATIONS = {
|
|
46
|
+
claude: { args: ['auth', 'login'], emailFlag: '--email', hint: 'Complete the Claude sign-in in your browser.' },
|
|
47
|
+
codex: { args: ['login'] },
|
|
48
|
+
};
|
|
49
|
+
/** Whether `connect` can drive this harness (isolation + a real native login). */
|
|
50
|
+
export function connectSupported(agent) {
|
|
51
|
+
const cap = nativeAccountCapability(agent);
|
|
52
|
+
return cap.scope === 'version' && !!LOGIN_INVOCATIONS[agent];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Named reason connect refuses a harness, or null when supported. Distinguishes
|
|
56
|
+
* "cannot isolate/name this login" (capability) from "no native login command
|
|
57
|
+
* wired yet" so the message is honest about which limit was hit.
|
|
58
|
+
*/
|
|
59
|
+
export function connectRefusal(agent) {
|
|
60
|
+
const capabilityRefusal = nativeAccountNamingRefusal(agent);
|
|
61
|
+
if (capabilityRefusal)
|
|
62
|
+
return capabilityRefusal;
|
|
63
|
+
const cap = nativeAccountCapability(agent);
|
|
64
|
+
if (cap.scope !== 'version') {
|
|
65
|
+
return `${agent} authentication is ${cap.scope}-scoped; connect creates per-account isolated homes, which needs a version-scoped login.`;
|
|
66
|
+
}
|
|
67
|
+
if (!LOGIN_INVOCATIONS[agent]) {
|
|
68
|
+
const supported = Object.keys(LOGIN_INVOCATIONS).sort().join(', ');
|
|
69
|
+
return `connect does not yet drive ${agent}'s native login. Supported: ${supported}.`;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
export function assertConnectSupported(agent) {
|
|
74
|
+
const reason = connectRefusal(agent);
|
|
75
|
+
if (reason)
|
|
76
|
+
throw new Error(reason);
|
|
77
|
+
}
|
|
78
|
+
export function loginInvocation(agent) {
|
|
79
|
+
const invocation = LOGIN_INVOCATIONS[agent];
|
|
80
|
+
if (!invocation)
|
|
81
|
+
throw new Error(`No native login command is wired for ${agent}.`);
|
|
82
|
+
return invocation;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Mint a fresh, opaque installation slot. `acct-<hex>` — alnum + hyphen only, so
|
|
86
|
+
* it satisfies VERSION_RE, and is visibly an account slot, not a release. It is
|
|
87
|
+
* RANDOM, never derived from the account name or identity: a name-derived slot
|
|
88
|
+
* recomputes an already-occupied home after a rename and lets a new connect
|
|
89
|
+
* overwrite another account's login (PHNX-3940 security fix). Retry-idempotency
|
|
90
|
+
* is instead provided by the device-scoped pending-connect map, and collision
|
|
91
|
+
* safety by allocating around occupied slots — see `allocateConnectSlot`.
|
|
92
|
+
*/
|
|
93
|
+
export function mintConnectLabel() {
|
|
94
|
+
return `acct-${crypto.randomBytes(6).toString('hex')}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the home to reuse for reconnecting an existing account: THIS box's
|
|
98
|
+
* recorded connect home first (device-scoped, survives an expired credential),
|
|
99
|
+
* else a currently-signed-in local home carrying its identity. Returns null when
|
|
100
|
+
* neither is known — a legacy account with no recorded or live home, for which
|
|
101
|
+
* connect adopts a fresh home.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveExistingHomeLabel(existing, deviceHome, signedInHomes) {
|
|
104
|
+
if (deviceHome)
|
|
105
|
+
return deviceHome;
|
|
106
|
+
const match = signedInHomes.find(h => h.agent === existing.agent && h.identityKey === existing.identityKey);
|
|
107
|
+
return match?.label ?? null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Decide the release-independent connect plan (pure). `existing` is the native
|
|
111
|
+
* account the name resolves to for THIS harness (or null for a new connect);
|
|
112
|
+
* `existingHomeLabel` is its reusable home from {@link resolveExistingHomeLabel};
|
|
113
|
+
* `freshSlot` is a safely-allocated opaque slot (see `allocateConnectSlot`) used
|
|
114
|
+
* for a new connect or an adopted reconnect home — NEVER a name-derived label.
|
|
115
|
+
*/
|
|
116
|
+
export function planConnect(input) {
|
|
117
|
+
if (input.existing) {
|
|
118
|
+
const reuse = input.existingHomeLabel;
|
|
119
|
+
return {
|
|
120
|
+
mode: 'reconnect',
|
|
121
|
+
agent: input.agent,
|
|
122
|
+
label: reuse ?? input.freshSlot,
|
|
123
|
+
name: input.existing.name,
|
|
124
|
+
existing: input.existing,
|
|
125
|
+
adoptedHome: !reuse,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return { mode: 'new', agent: input.agent, label: input.freshSlot, name: input.name };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Safely allocate the opaque slot for a NEW connect or an ADOPTED reconnect home
|
|
132
|
+
* (PHNX-3940 security fix). Never reuses an identity-bearing slot:
|
|
133
|
+
*
|
|
134
|
+
* - `occupied` is every slot already owned by an account's home OR currently
|
|
135
|
+
* signed in — an allocated slot is guaranteed disjoint from it, so a new
|
|
136
|
+
* connect can never land on another account's home (the rename-collision flaw).
|
|
137
|
+
* - A named connect first reuses its device-scoped PENDING slot (a prior
|
|
138
|
+
* failed/cancelled attempt) IF that slot is not occupied, so a retry lands in
|
|
139
|
+
* the same fresh home instead of orphaning a new one.
|
|
140
|
+
* - Otherwise it mints random slots until one is neither occupied nor installed.
|
|
141
|
+
*/
|
|
142
|
+
export function allocateConnectSlot(input) {
|
|
143
|
+
const mint = input.mint ?? mintConnectLabel;
|
|
144
|
+
// Retry reuse is only for a NEW named connect (pending is keyed by name); a
|
|
145
|
+
// pending slot that has since become occupied is abandoned, never overwritten.
|
|
146
|
+
if (!input.existing && input.name && input.pending && !input.occupied.has(input.pending)) {
|
|
147
|
+
return input.pending;
|
|
148
|
+
}
|
|
149
|
+
let slot = mint();
|
|
150
|
+
while (input.occupied.has(slot) || input.installedLabels.has(slot))
|
|
151
|
+
slot = mint();
|
|
152
|
+
return slot;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Fail-closed identity check after the login completes (pure).
|
|
156
|
+
*
|
|
157
|
+
* - Not signed in (no live credential) → the login did not complete; a metadata
|
|
158
|
+
* identity key alone is NOT proof, so `signedIn` is required.
|
|
159
|
+
* - Reconnect whose completed identity differs from the account's → REFUSE
|
|
160
|
+
* registering the binding. The native credential in that home DID change (the
|
|
161
|
+
* user signed in), so the message says the account BINDING is left unchanged —
|
|
162
|
+
* it does not claim nothing happened.
|
|
163
|
+
* A new connect accepts whatever identity signed in (that is the account being
|
|
164
|
+
* created); the caller registers it.
|
|
165
|
+
*/
|
|
166
|
+
export function verifyConnectedIdentity(plan, observed) {
|
|
167
|
+
if (!observed.signedIn || !observed.identityKey) {
|
|
168
|
+
throw new Error(`No ${plan.agent} login completed in ${plan.agent}@${plan.label} (no live credential). Nothing was connected.`);
|
|
169
|
+
}
|
|
170
|
+
if (plan.mode === 'reconnect' && plan.existing && observed.identityKey !== plan.existing.identityKey) {
|
|
171
|
+
throw new Error(`The ${plan.agent} login that just completed in ${plan.agent}@${plan.label} is a different identity `
|
|
172
|
+
+ `(${observed.identityKey}) than account '${plan.existing.name}' (${plan.existing.identityKey}). `
|
|
173
|
+
+ `Account '${plan.existing.name}' still points at its original identity (binding unchanged); `
|
|
174
|
+
+ `connect a NEW account for this login instead.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Resolve the existing native account a connect `name` refers to for a harness. */
|
|
178
|
+
export function findConnectAccount(agent, name, meta) {
|
|
179
|
+
if (!name)
|
|
180
|
+
return null;
|
|
181
|
+
const needle = name.toLowerCase();
|
|
182
|
+
return listNativeAccounts(meta).find(a => a.agent === agent && (a.id === name || a.name.toLowerCase() === needle || a.identityLabel?.toLowerCase() === needle)) ?? null;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Drive one `agents accounts connect`. Pure decisions (plan, fail-closed verify)
|
|
186
|
+
* come from the helpers above; the install + login + identity read are injected.
|
|
187
|
+
*
|
|
188
|
+
* The default runners are loaded via dynamic import so this module — reachable
|
|
189
|
+
* from `account-registry` consumers — never statically pulls in the install
|
|
190
|
+
* engine or exec path and closes an import cycle (the intended pattern here).
|
|
191
|
+
*
|
|
192
|
+
* `stateDir` is only for tests: override the runtime-state dir used by the
|
|
193
|
+
* per-harness auth-operation mutex (defaults to the real runtime state dir).
|
|
194
|
+
*/
|
|
195
|
+
export async function runConnect(agent, name, opts, runners) {
|
|
196
|
+
assertConnectSupported(agent);
|
|
197
|
+
// Acquire the per-harness auth-operation mutex BEFORE any meta read, slot
|
|
198
|
+
// allocation, or name validation — two parallel connects for the same harness
|
|
199
|
+
// would otherwise both see "name available" and "slot free", then race to
|
|
200
|
+
// register the same slot or the same name, overwriting one account's home.
|
|
201
|
+
const lock = acquireAuthOperationLock(agent, opts.stateDir);
|
|
202
|
+
try {
|
|
203
|
+
return await _runConnectLocked(agent, name, opts, lock, runners);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
lock.release();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function _runConnectLocked(agent, name, opts, lock, runners) {
|
|
210
|
+
const run = runners ?? await defaultConnectRunners();
|
|
211
|
+
const registry = await import('../account-registry.js');
|
|
212
|
+
lock.assertHeld();
|
|
213
|
+
// Re-read meta AFTER acquiring the lock so we see any writes a concurrent
|
|
214
|
+
// connect committed before we were serialized past it. `opts.meta` was a
|
|
215
|
+
// snapshot taken before the lock was available and must not be used here.
|
|
216
|
+
const meta = readMeta();
|
|
217
|
+
const existing = findConnectAccount(agent, name, meta);
|
|
218
|
+
// Validate the requested NAME and its collisions BEFORE any install or login,
|
|
219
|
+
// so a bad name never mints an orphan home and drives a login that can't be
|
|
220
|
+
// recorded. Only a genuinely NEW named connect needs this (a reconnect names an
|
|
221
|
+
// account that already passed the check).
|
|
222
|
+
if (!existing && name)
|
|
223
|
+
registry.assertNativeAccountNameAvailable(name, agent);
|
|
224
|
+
const signedIn = await run.signedInHomes();
|
|
225
|
+
lock.assertHeld();
|
|
226
|
+
const existingHomeLabel = existing
|
|
227
|
+
? resolveExistingHomeLabel(existing, registry.nativeAccountHome(existing.id, meta), signedIn)
|
|
228
|
+
: null;
|
|
229
|
+
// SECURITY (PHNX-3940): allocate a fresh slot that is DISJOINT from every
|
|
230
|
+
// occupied home — a slot already owned by an account OR currently signed in.
|
|
231
|
+
// This is what stops a NEW connect (e.g. `connect work` after `work` was
|
|
232
|
+
// renamed `personal`) from landing on and overwriting another account's login.
|
|
233
|
+
const occupied = new Set([
|
|
234
|
+
...registry.ownedConnectHomeLabels(meta),
|
|
235
|
+
...signedIn.filter(h => h.agent === agent).map(h => h.label),
|
|
236
|
+
]);
|
|
237
|
+
const freshSlot = allocateConnectSlot({
|
|
238
|
+
agent,
|
|
239
|
+
name,
|
|
240
|
+
existing,
|
|
241
|
+
occupied,
|
|
242
|
+
installedLabels: new Set(run.installedLabels(agent)),
|
|
243
|
+
pending: (!existing && name) ? registry.pendingConnectSlot(agent, name, meta) : null,
|
|
244
|
+
});
|
|
245
|
+
// Record the in-flight slot for a named connect so a failed retry reuses it.
|
|
246
|
+
if (!existing && name && freshSlot !== registry.pendingConnectSlot(agent, name, meta)) {
|
|
247
|
+
registry.setPendingConnectSlot(agent, name, freshSlot);
|
|
248
|
+
}
|
|
249
|
+
const plan = planConnect({ agent, name, existing, existingHomeLabel, freshSlot });
|
|
250
|
+
// Pre-launch guard (both modes): if the chosen home is already installed AND
|
|
251
|
+
// signed in, refuse to launch a login that would OVERWRITE its identity —
|
|
252
|
+
// unless it is the SAME identity we are (re)connecting. For a NEW connect any
|
|
253
|
+
// signed-in identity is a stranger; for a reconnect only a DIFFERENT one is.
|
|
254
|
+
if (run.installedLabels(agent).includes(plan.label)) {
|
|
255
|
+
const current = await run.observeIdentity(agent, plan.label);
|
|
256
|
+
lock.assertHeld();
|
|
257
|
+
const intended = existing?.identityKey;
|
|
258
|
+
if (current.signedIn && current.identityKey && current.identityKey !== intended) {
|
|
259
|
+
throw new Error(`${agent}@${plan.label} is currently signed in as ${current.identityKey}`
|
|
260
|
+
+ `${existing ? ` (not account '${existing.name}')` : ''}. `
|
|
261
|
+
+ `Refusing to launch a login that would overwrite it.`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Install the current release into the account's home. A reconnect that reuses
|
|
265
|
+
// an already-installed home skips the install (reuse, don't re-mint); a new
|
|
266
|
+
// account (or an adopted reconnect home) installs the current release under its
|
|
267
|
+
// opaque label even when the same release is already installed elsewhere. A
|
|
268
|
+
// retried named connect reuses the same pending slot rather than orphaning.
|
|
269
|
+
const alreadyInstalled = run.installedLabels(agent).includes(plan.label);
|
|
270
|
+
lock.assertHeld();
|
|
271
|
+
if (!(alreadyInstalled && (plan.mode === 'reconnect' || !!name))) {
|
|
272
|
+
opts.onProgress?.(`Installing ${agent} into ${plan.label}…`);
|
|
273
|
+
const install = await run.install(agent, plan.label, opts.onProgress);
|
|
274
|
+
lock.assertHeld();
|
|
275
|
+
if (!install.success)
|
|
276
|
+
throw new Error(`Could not install ${agent} for the account home: ${install.error ?? 'unknown error'}`);
|
|
277
|
+
}
|
|
278
|
+
const invocation = loginInvocation(agent);
|
|
279
|
+
if (invocation.hint)
|
|
280
|
+
opts.onProgress?.(invocation.hint);
|
|
281
|
+
const loginEmail = existing?.identityLabel;
|
|
282
|
+
lock.assertHeld();
|
|
283
|
+
const login = await run.launchLogin(agent, plan.label, invocation, loginEmail, lock.signal);
|
|
284
|
+
lock.assertHeld();
|
|
285
|
+
// A cancelled or failed login exits non-zero — do NOT read stale metadata and
|
|
286
|
+
// report success. A NAMED connect can retry the same home (its slot is pending);
|
|
287
|
+
// an UNNAMED connect has no pending slot, so a re-run allocates a new home.
|
|
288
|
+
if (login.code !== 0) {
|
|
289
|
+
const retry = name ? 're-run to retry the same home' : 're-run to try again (a new home is allocated)';
|
|
290
|
+
throw new Error(`${agent} login did not complete (exit ${login.code ?? 'null'}) in ${agent}@${plan.label}. Nothing was recorded; ${retry}.`);
|
|
291
|
+
}
|
|
292
|
+
const observed = await run.observeIdentity(agent, plan.label);
|
|
293
|
+
lock.assertHeld();
|
|
294
|
+
verifyConnectedIdentity(plan, observed);
|
|
295
|
+
const identityKey = observed.identityKey;
|
|
296
|
+
// Persist THIS box's account⇄home binding (device-scoped) so a later reconnect
|
|
297
|
+
// reuses this exact home even after the credential expires, and clear the
|
|
298
|
+
// in-flight pending slot now that it is a real account home.
|
|
299
|
+
let becameDefault = false;
|
|
300
|
+
let nameHint;
|
|
301
|
+
if (plan.mode === 'new') {
|
|
302
|
+
if (name) {
|
|
303
|
+
const account = registry.addNativeAccount(name, agent, identityKey, observed.email ?? undefined, 'version');
|
|
304
|
+
registry.setNativeAccountHome(account.id, plan.label);
|
|
305
|
+
registry.clearPendingConnectSlot(agent, name);
|
|
306
|
+
// Select this account as the harness default ONLY if none is configured —
|
|
307
|
+
// never override an existing choice, and never force a duplicate name.
|
|
308
|
+
becameDefault = registry.setDefaultAccountIfAbsent(agent, account.name);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
// No name was given: do not invent/force one. Point the user at the command
|
|
312
|
+
// that names this login so it becomes selectable and default-eligible.
|
|
313
|
+
nameHint = `agents accounts label ${agent} <name>`;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else if (existing) {
|
|
317
|
+
registry.setNativeAccountHome(existing.id, plan.label);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
mode: plan.mode,
|
|
321
|
+
agent,
|
|
322
|
+
label: plan.label,
|
|
323
|
+
name: plan.name ?? name,
|
|
324
|
+
identityKey,
|
|
325
|
+
email: observed.email,
|
|
326
|
+
releaseVersion: observed.releaseVersion,
|
|
327
|
+
becameDefault,
|
|
328
|
+
nameHint,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** Real runners: the install engine, an inherited-stdio login spawn, and identity read. */
|
|
332
|
+
async function defaultConnectRunners() {
|
|
333
|
+
const [{ installVersion, getVersionHomePath, listInstalledVersions }, store, agentsMod, execMod] = await Promise.all([
|
|
334
|
+
import('../installations/versions.js'),
|
|
335
|
+
import('../installations/store.js'),
|
|
336
|
+
import('../agents.js'),
|
|
337
|
+
import('../exec.js'),
|
|
338
|
+
]);
|
|
339
|
+
const { readInstallation } = store;
|
|
340
|
+
const { getAccountInfo } = agentsMod;
|
|
341
|
+
const { buildExecEnv } = execMod;
|
|
342
|
+
const { nativeAccountCapability, nativeIdentityKey } = await import('../account-capabilities.js');
|
|
343
|
+
const { runNativeAccountCommand } = await import('../installations/native-command.js');
|
|
344
|
+
return {
|
|
345
|
+
installedLabels: (agent) => listInstalledVersions(agent),
|
|
346
|
+
install: async (agent, label, onProgress) => {
|
|
347
|
+
const result = await installVersion(agent, 'latest', onProgress, { installationLabel: label });
|
|
348
|
+
return { success: result.success, error: result.error };
|
|
349
|
+
},
|
|
350
|
+
launchLogin: async (agent, label, invocation, email, signal) => {
|
|
351
|
+
// Pin the harness's own config-dir env (CLAUDE_CONFIG_DIR / CODEX_HOME) to
|
|
352
|
+
// this label's isolated home so the native login lands there — no
|
|
353
|
+
// credential is copied; the login writes into the home it authenticates.
|
|
354
|
+
const env = buildExecEnv({ agent, version: label, configVersion: label, interactive: true, mode: 'auto', effort: 'auto', cwd: process.cwd() });
|
|
355
|
+
// Strip any ambient provider API-key / setup-token env so the native login
|
|
356
|
+
// authenticates as the human's OAuth identity, not an injected credential
|
|
357
|
+
// that would impersonate a different account into this home.
|
|
358
|
+
for (const key of PROVIDER_AUTH_ENV_KEYS)
|
|
359
|
+
delete env[key];
|
|
360
|
+
const args = email && invocation.emailFlag ? [...invocation.args, invocation.emailFlag, email] : invocation.args;
|
|
361
|
+
return runNativeAccountCommand(agent, label, args, env, signal);
|
|
362
|
+
},
|
|
363
|
+
observeIdentity: async (agent, label) => {
|
|
364
|
+
const home = getVersionHomePath(agent, label);
|
|
365
|
+
const info = await getAccountInfo(agent, home);
|
|
366
|
+
const { isLaunchableSignedIn } = await import('../account-catalog.js');
|
|
367
|
+
return {
|
|
368
|
+
identityKey: nativeIdentityKey(info, nativeAccountCapability(agent)),
|
|
369
|
+
email: info.email,
|
|
370
|
+
releaseVersion: readInstallation(agent, label)?.releaseVersion ?? null,
|
|
371
|
+
// Strict: a live credential in THIS home, not a bare metadata identity.
|
|
372
|
+
signedIn: isLaunchableSignedIn(agent, home, info),
|
|
373
|
+
};
|
|
374
|
+
},
|
|
375
|
+
signedInHomes: async () => {
|
|
376
|
+
const { collectNativeHomeRows } = await import('../account-catalog.js');
|
|
377
|
+
const rows = await collectNativeHomeRows();
|
|
378
|
+
return rows
|
|
379
|
+
.filter(r => r.signedIn && (r.accountKey ?? r.email))
|
|
380
|
+
.map(r => ({ agent: r.agent, identityKey: (r.accountKey ?? r.email.toLowerCase()), label: r.label }));
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
package/dist/lib/capabilities.js
CHANGED
|
@@ -11,6 +11,7 @@ import { AGENTS, MANAGED_AGENT_IDS } from './agents.js';
|
|
|
11
11
|
// agent-spec/primitives is a leaf module — importing it creates no cycle, and it
|
|
12
12
|
// is the only compareVersions that honors OpenClaw's `-N` rebuild suffix.
|
|
13
13
|
import { compareVersions } from './agent-spec/primitives.js';
|
|
14
|
+
import { installedReleaseFor } from './installations/store.js';
|
|
14
15
|
function getCapability(agent, cap) {
|
|
15
16
|
// Guard against unknown agent ids (e.g. a caller passing "claude@2.1.168"
|
|
16
17
|
// instead of "claude"). Without this, AGENTS[agent] is undefined and the
|
|
@@ -48,6 +49,7 @@ export function supports(agent, cap, version) {
|
|
|
48
49
|
return { ok: true };
|
|
49
50
|
if (!version)
|
|
50
51
|
return { ok: true };
|
|
52
|
+
version = installedReleaseFor(agent, version);
|
|
51
53
|
if (c.since && compareVersions(version, c.since) < 0) {
|
|
52
54
|
return { ok: false, reason: 'too_old', need: `>= ${c.since}` };
|
|
53
55
|
}
|
package/dist/lib/commands.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import * as fs from 'fs';
|
|
10
10
|
import { compareVersions } from './agent-spec/primitives.js';
|
|
11
|
+
import { installedReleaseFor } from './installations/store.js';
|
|
11
12
|
import * as path from 'path';
|
|
12
13
|
import * as yaml from 'yaml';
|
|
13
14
|
import { AGENTS, ensureCommandsDir, agentConfigDirName, resolveAgentName } from './agents.js';
|
|
@@ -48,6 +49,7 @@ export function commandAppliesTo(agent, version, metadata) {
|
|
|
48
49
|
if (metadata?.agents?.length && !metadata.agents.includes(agent)) {
|
|
49
50
|
return { ok: false, reason: 'agent_excluded' };
|
|
50
51
|
}
|
|
52
|
+
version = installedReleaseFor(agent, version);
|
|
51
53
|
if (metadata?.since && compareVersions(version, metadata.since) < 0) {
|
|
52
54
|
return { ok: false, reason: 'too_old', need: `>= ${metadata.since}` };
|
|
53
55
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { AgentId } from './types.js';
|
|
10
10
|
import { type ModelTier } from './model-tiers.js';
|
|
11
11
|
/** The top-level scope of a unified config key. */
|
|
12
|
-
export type ConfigScope = 'run' | 'interactive' | 'auto' | 'browser' | 'project' | 'device' | 'summarizer';
|
|
12
|
+
export type ConfigScope = 'run' | 'interactive' | 'auto' | 'browser' | 'project' | 'device' | 'summarizer' | 'updates';
|
|
13
13
|
/** A run-time default key: model, mode, effort, or tier override. */
|
|
14
14
|
export interface ParsedRunConfigKey {
|
|
15
15
|
scope: 'run';
|
|
@@ -51,7 +51,16 @@ export interface ParsedSummarizerConfigKey {
|
|
|
51
51
|
scope: 'summarizer';
|
|
52
52
|
property: 'enabled' | 'baseUrl' | 'model';
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The managed-harness auto-update switch (PHNX-3940): `updates.auto` (global)
|
|
56
|
+
* or `updates.<agent>.auto` (one harness — `agent` is set).
|
|
57
|
+
*/
|
|
58
|
+
export interface ParsedUpdatesConfigKey {
|
|
59
|
+
scope: 'updates';
|
|
60
|
+
property: 'auto';
|
|
61
|
+
agent?: AgentId;
|
|
62
|
+
}
|
|
63
|
+
export type ParsedConfigKey = ParsedRunConfigKey | ParsedInteractiveConfigKey | ParsedAutoConfigKey | ParsedBrowserConfigKey | ParsedProjectConfigKey | ParsedDeviceConfigKey | ParsedSummarizerConfigKey | ParsedUpdatesConfigKey;
|
|
55
64
|
export type DeviceConfigProperty = 'role' | 'max-agents' | 'scheduler' | 'daemon' | 'watchdog' | 'tmux' | 'browser.remote-control' | 'browser.task-idle-minutes' | 'notes' | 'browser.profile' | 'browser.viewer';
|
|
56
65
|
/** Normalize agent@version to use `@` consistently. */
|
|
57
66
|
export declare function formatAgentVersion(agent: AgentId, version: string): string;
|
package/dist/lib/config-keys.js
CHANGED
|
@@ -98,6 +98,17 @@ export function parseConfigKey(key) {
|
|
|
98
98
|
if (summarizerMatch) {
|
|
99
99
|
return { scope: 'summarizer', property: summarizerMatch[1] };
|
|
100
100
|
}
|
|
101
|
+
if (raw === 'updates.auto') {
|
|
102
|
+
return { scope: 'updates', property: 'auto' };
|
|
103
|
+
}
|
|
104
|
+
const updatesAgentMatch = raw.match(/^updates\.(.+)\.auto$/);
|
|
105
|
+
if (updatesAgentMatch) {
|
|
106
|
+
const agentPart = updatesAgentMatch[1].toLowerCase();
|
|
107
|
+
if (!(agentPart in AGENTS)) {
|
|
108
|
+
throw new Error(`Unknown agent '${updatesAgentMatch[1]}' in '${key}'. Known agents: ${Object.keys(AGENTS).join(', ')}.`);
|
|
109
|
+
}
|
|
110
|
+
return { scope: 'updates', agent: agentPart, property: 'auto' };
|
|
111
|
+
}
|
|
101
112
|
const deviceMatch = raw.match(/^devices\.(.+)\.(role|max-agents|scheduler|daemon|watchdog|tmux|notes|browser\.remote-control|browser\.task-idle-minutes|browser\.profile|browser\.viewer)$/);
|
|
102
113
|
if (deviceMatch) {
|
|
103
114
|
return {
|
|
@@ -125,6 +136,9 @@ export function parseConfigKey(key) {
|
|
|
125
136
|
if (raw.startsWith('summarizer.')) {
|
|
126
137
|
throw new Error(`Invalid summarizer config key '${key}'. Use summarizer.enabled, summarizer.baseUrl, or summarizer.model.`);
|
|
127
138
|
}
|
|
139
|
+
if (raw.startsWith('updates.')) {
|
|
140
|
+
throw new Error(`Invalid updates config key '${key}'. Use updates.auto or updates.<agent>.auto.`);
|
|
141
|
+
}
|
|
128
142
|
if (raw.startsWith('devices.')) {
|
|
129
143
|
throw new Error(`Invalid device config key '${key}'. Expected devices.<name>.<${DEVICE_CONFIG_PROPERTIES.join('|')}>.`);
|
|
130
144
|
}
|
|
@@ -152,6 +166,8 @@ export function formatConfigKey(parsed) {
|
|
|
152
166
|
return `devices.${parsed.device}.${parsed.property}`;
|
|
153
167
|
case 'summarizer':
|
|
154
168
|
return `summarizer.${parsed.property}`;
|
|
169
|
+
case 'updates':
|
|
170
|
+
return parsed.agent ? `updates.${parsed.agent}.auto` : 'updates.auto';
|
|
155
171
|
}
|
|
156
172
|
}
|
|
157
173
|
/** List every canonical key the command documents, with wildcards expanded to a concrete example. */
|
|
@@ -161,7 +177,7 @@ export function listKnownConfigKeys() {
|
|
|
161
177
|
for (const tier of MODEL_TIERS) {
|
|
162
178
|
keys.push(`run.<agent@version>.tier.${tier}`);
|
|
163
179
|
}
|
|
164
|
-
keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root', 'summarizer.enabled', 'summarizer.baseUrl', 'summarizer.model');
|
|
180
|
+
keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root', 'summarizer.enabled', 'summarizer.baseUrl', 'summarizer.model', 'updates.auto', 'updates.<agent>.auto');
|
|
165
181
|
for (const prop of DEVICE_CONFIG_PROPERTIES) {
|
|
166
182
|
keys.push(`devices.<name>.${prop}`);
|
|
167
183
|
}
|
|
@@ -233,5 +249,9 @@ export function configKeyStorageHint(parsed) {
|
|
|
233
249
|
: parsed.property === 'baseUrl' ? 'summarizerBaseUrl' : 'summarizerModel';
|
|
234
250
|
return `config.${yamlKey} (central agents.yaml; syncs fleet-wide)`;
|
|
235
251
|
}
|
|
252
|
+
case 'updates':
|
|
253
|
+
return parsed.agent
|
|
254
|
+
? `config.updatesAgentAuto.${parsed.agent} (central agents.yaml; syncs fleet-wide)`
|
|
255
|
+
: 'config.updatesAuto (central agents.yaml; syncs fleet-wide)';
|
|
236
256
|
}
|
|
237
257
|
}
|
|
@@ -42,6 +42,7 @@ import { WatchdogService } from './watchdog-service.js';
|
|
|
42
42
|
import { DeviceProbeService } from './device-probe-service.js';
|
|
43
43
|
import { SelfHealService } from './self-heal-service.js';
|
|
44
44
|
import { SelfUpdateService } from './self-update-service.js';
|
|
45
|
+
import { HarnessUpdateService } from './harness-update-service.js';
|
|
45
46
|
import { KeychainReapService } from './keychain-reap-service.js';
|
|
46
47
|
import { AuthSyncService } from './auth-sync-service.js';
|
|
47
48
|
import { UsageSyncService } from './usage-sync-service.js';
|
|
@@ -1090,6 +1091,10 @@ export async function runDaemon() {
|
|
|
1090
1091
|
supervisor.register(new SelfUpdateService());
|
|
1091
1092
|
else
|
|
1092
1093
|
log('INFO', 'Self-update service disabled');
|
|
1094
|
+
if (isEnabled('harness-update'))
|
|
1095
|
+
supervisor.register(new HarnessUpdateService());
|
|
1096
|
+
else
|
|
1097
|
+
log('INFO', 'Harness-update service disabled');
|
|
1093
1098
|
if (isEnabled('keychain-reap'))
|
|
1094
1099
|
supervisor.register(new KeychainReapService());
|
|
1095
1100
|
else
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon harness-update service (PHNX-3940).
|
|
3
|
+
*
|
|
4
|
+
* Runs the automatic-update pass (`installations/update-runtime.ts`) on a
|
|
5
|
+
* schedule so a managed, transactional npm harness (Claude, Codex, …) stays
|
|
6
|
+
* current with no operator action, subject to the `updates.auto` /
|
|
7
|
+
* `updates.<agent>.auto` switches and each installation's own update policy.
|
|
8
|
+
*
|
|
9
|
+
* The pass itself does real, synchronous filesystem work per installation —
|
|
10
|
+
* staging an npm install into a sibling directory, `fs.renameSync`/`fs.cpSync`
|
|
11
|
+
* swaps, `fs.rmSync` cleanup — potentially across several installations in one
|
|
12
|
+
* tick. Running that inline on the daemon's own event loop would stall every
|
|
13
|
+
* other service (secrets broker, browser IPC, the scheduler) for the
|
|
14
|
+
* duration, the same class of problem `self-update-service.ts` solves for the
|
|
15
|
+
* CLI's OWN upgrade by installing into a fresh process it then exits into.
|
|
16
|
+
* Here there is no "exit and let the supervisor restart" option (the daemon
|
|
17
|
+
* itself isn't what's being updated), so instead this tick SPAWNS a bounded
|
|
18
|
+
* child (`agents __harness-update-run`) over a Node IPC channel and only waits
|
|
19
|
+
* on that child — every sync fs call happens in the child's own event loop /
|
|
20
|
+
* thread pool, never this one.
|
|
21
|
+
*
|
|
22
|
+
* Cancellation is COOPERATIVE and cross-platform (PHNX-3940). On the tick's
|
|
23
|
+
* deadline or daemon shutdown — both delivered as the supervisor aborting the
|
|
24
|
+
* tick's `AbortSignal` — the daemon does NOT force-kill the child (execFile's
|
|
25
|
+
* `timeout`/`signal` would, unconditionally on Windows, mid-swap). It SENDS the
|
|
26
|
+
* child an IPC cancel message; the child stops at its next safe boundary (see
|
|
27
|
+
* `installations/update-cancellation.ts` + `update.ts`'s `shouldCancel`) and
|
|
28
|
+
* exits on its own, and the daemon waits for that TRUE exit. A wedged child that
|
|
29
|
+
* ignores the request past a generous grace — never the normal path, since the
|
|
30
|
+
* child's own npm/probe work is bounded — is force-reaped only as an
|
|
31
|
+
* orphan-prevention backstop, and that abnormal case is reported as a failure,
|
|
32
|
+
* never as a clean pass.
|
|
33
|
+
*/
|
|
34
|
+
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
35
|
+
import type { DaemonServiceId } from '../daemon-services.js';
|
|
36
|
+
/**
|
|
37
|
+
* Cancellation deadline per tick. A real pass can touch several installations, each an npm
|
|
38
|
+
* install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
|
|
39
|
+
* probes; 10 minutes leaves headroom for a handful of harnesses in one pass
|
|
40
|
+
* while staying comfortably under the 15-minute cadence above.
|
|
41
|
+
*/
|
|
42
|
+
export declare const HARNESS_UPDATE_DEADLINE_MS: number;
|
|
43
|
+
/**
|
|
44
|
+
* How long to wait for the child to exit AFTER a cooperative cancel before
|
|
45
|
+
* force-reaping it as an orphan backstop. The child's own work is bounded — one
|
|
46
|
+
* npm install (`INSTALL_TIMEOUT_MS` = 120s in strategies.ts) plus two launch
|
|
47
|
+
* probes, then a fast synchronous swap — so a healthy child stops well within
|
|
48
|
+
* this window. A slow staging/postinstall can also exhaust it, but a delivered
|
|
49
|
+
* cancel prevents that stage from committing. The backstop kills the process
|
|
50
|
+
* group on POSIX and the worker on Windows; the tick reports a failure.
|
|
51
|
+
*/
|
|
52
|
+
export declare const HARNESS_UPDATE_CANCEL_GRACE_MS: number;
|
|
53
|
+
export interface HarnessUpdateOutcome {
|
|
54
|
+
ran: boolean;
|
|
55
|
+
reason?: string;
|
|
56
|
+
exitCode?: number | null;
|
|
57
|
+
stdout?: string;
|
|
58
|
+
/** True when the pass was asked to stop (deadline/shutdown) and did so cooperatively. */
|
|
59
|
+
cancelled?: boolean;
|
|
60
|
+
}
|
|
61
|
+
export interface CooperativeChildResult {
|
|
62
|
+
exitCode: number | null;
|
|
63
|
+
stdout: string;
|
|
64
|
+
/** True when a cancel was requested during this child's life (deadline/shutdown). */
|
|
65
|
+
cancelled: boolean;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Dependency seam so tests can assert the tick's decision/spawn logic without
|
|
69
|
+
* actually installing anything. Production always uses
|
|
70
|
+
* {@link defaultHarnessUpdateDeps}.
|
|
71
|
+
*/
|
|
72
|
+
export interface HarnessUpdateDeps {
|
|
73
|
+
runAutoUpdatePass(signal: AbortSignal): Promise<CooperativeChildResult>;
|
|
74
|
+
}
|
|
75
|
+
export declare function defaultHarnessUpdateDeps(): HarnessUpdateDeps;
|
|
76
|
+
/**
|
|
77
|
+
* Spawn `command args` as a child over a Node IPC channel and drive it to a TRUE
|
|
78
|
+
* completion, requesting a cooperative stop (never a kill) when `signal` aborts —
|
|
79
|
+
* the supervisor aborts it on the tick's deadline OR on daemon shutdown.
|
|
80
|
+
*
|
|
81
|
+
* Resolves with the child's real exit code and captured output. Rejects only on:
|
|
82
|
+
* - a spawn failure (missing binary) — a genuine service failure; and
|
|
83
|
+
* - the child having to be force-reaped past the grace window, or dying to an
|
|
84
|
+
* external signal — never a clean pass, so the tick logs it as ERROR instead
|
|
85
|
+
* of reading a killed process as a completed update.
|
|
86
|
+
*
|
|
87
|
+
* A non-zero exit with no kill is resolved (not rejected): the pass exits
|
|
88
|
+
* non-zero for a per-installation vendor error, which is a normal tick outcome.
|
|
89
|
+
*
|
|
90
|
+
* Exported for the real-subprocess cancellation tests.
|
|
91
|
+
*/
|
|
92
|
+
export declare function driveCooperativeChild(command: string, args: string[], signal: AbortSignal, graceMs: number, spawnOpts?: {
|
|
93
|
+
env?: NodeJS.ProcessEnv;
|
|
94
|
+
cwd?: string;
|
|
95
|
+
}): Promise<CooperativeChildResult>;
|
|
96
|
+
/**
|
|
97
|
+
* Core decision + spawn, shared so a future on-demand trigger (mirroring
|
|
98
|
+
* `triggerSelfUpdateInBackground`) can reuse it. Returns rather than throws —
|
|
99
|
+
* the periodic tick logs the outcome and moves on either way.
|
|
100
|
+
*/
|
|
101
|
+
export declare function runHarnessUpdateTick(ctx: DaemonContext, signal: AbortSignal, deps?: HarnessUpdateDeps): Promise<HarnessUpdateOutcome>;
|
|
102
|
+
export declare class HarnessUpdateService extends BasePeriodicService {
|
|
103
|
+
readonly id: DaemonServiceId;
|
|
104
|
+
readonly intervalMs: number;
|
|
105
|
+
readonly deadlineMs: number;
|
|
106
|
+
readonly startupDelayMs = 60000;
|
|
107
|
+
protected onStart(_ctx: DaemonContext): Promise<void>;
|
|
108
|
+
protected onStop(): Promise<void>;
|
|
109
|
+
protected onTick(ctx: DaemonContext, signal: AbortSignal): Promise<void>;
|
|
110
|
+
}
|