gipity 1.0.412 → 1.0.413
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.js +14 -5
- package/dist/commands/relay.js +7 -0
- package/dist/relay/agent-token.js +75 -0
- package/dist/relay/daemon.js +28 -2
- package/dist/relay/state.js +14 -2
- package/package.json +1 -1
package/dist/auth.js
CHANGED
|
@@ -166,6 +166,15 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
166
166
|
const fresh = (a) => Date.now() <= new Date(a.expiresAt).getTime() - buffer;
|
|
167
167
|
if (!force && fresh(auth))
|
|
168
168
|
return; // fast path: token still good, no lock needed
|
|
169
|
+
// The specific access token the caller saw (the one the server just rejected on the
|
|
170
|
+
// force path). A locally-"fresh" token EQUAL to this can't be trusted - it's exactly
|
|
171
|
+
// what got a 401 - but a DIFFERENT fresh token means a sibling successfully rotated
|
|
172
|
+
// while we waited, and we should adopt it instead of re-refreshing with the (now
|
|
173
|
+
// single-use-consumed) refresh token. Without this, N concurrent relay dispatches
|
|
174
|
+
// that all 401 at once serialize on the lock, the first refreshes, and every other
|
|
175
|
+
// one re-tries the consumed token and hard-fails with "Session expired".
|
|
176
|
+
const rejectedAccess = auth.accessToken;
|
|
177
|
+
const adoptable = (a) => !!a && fresh(a) && (!force || a.accessToken !== rejectedAccess);
|
|
169
178
|
// If the refresh token itself has expired, re-login is genuinely required; leave the
|
|
170
179
|
// expired auth in place so the caller's existing 401 path prompts `gipity login`.
|
|
171
180
|
const refreshExp = decodeJwtExp(auth.refreshToken);
|
|
@@ -175,10 +184,10 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
175
184
|
const release = await acquireRefreshLock();
|
|
176
185
|
try {
|
|
177
186
|
// Re-check under the lock: a sibling may have refreshed while we waited. Under
|
|
178
|
-
// force we
|
|
179
|
-
//
|
|
187
|
+
// force we adopt only a token DIFFERENT from the one that was just rejected (a
|
|
188
|
+
// sibling's freshly-rotated token), never the rejected one itself.
|
|
180
189
|
const held = readAuthFresh();
|
|
181
|
-
if (
|
|
190
|
+
if (adoptable(held)) {
|
|
182
191
|
cached = held;
|
|
183
192
|
return;
|
|
184
193
|
}
|
|
@@ -186,7 +195,7 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
186
195
|
const apiBase = resolveApiBase();
|
|
187
196
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
188
197
|
const cur = readAuthFresh(); // a sibling may have just refreshed for us
|
|
189
|
-
if (
|
|
198
|
+
if (adoptable(cur)) {
|
|
190
199
|
cached = cur;
|
|
191
200
|
return;
|
|
192
201
|
}
|
|
@@ -218,7 +227,7 @@ export async function refreshTokenIfNeeded(force = false) {
|
|
|
218
227
|
// sibling's fresh token just landed; otherwise stop and let the caller re-login.
|
|
219
228
|
if (res.status === 401 || res.status === 403) {
|
|
220
229
|
const after = readAuthFresh();
|
|
221
|
-
if (
|
|
230
|
+
if (adoptable(after)) {
|
|
222
231
|
cached = after;
|
|
223
232
|
return;
|
|
224
233
|
}
|
package/dist/commands/relay.js
CHANGED
|
@@ -13,6 +13,7 @@ import { confirm } from '../utils.js';
|
|
|
13
13
|
import { bold, brand, dim, success, error as clrError, muted, } from '../colors.js';
|
|
14
14
|
import * as state from '../relay/state.js';
|
|
15
15
|
import * as daemon from '../relay/daemon.js';
|
|
16
|
+
import { revokeRelayAgentToken } from '../relay/agent-token.js';
|
|
16
17
|
import { UnsupportedPlatformError } from '../relay/installers.js';
|
|
17
18
|
import { pairDevice, startDaemon, installAutostart, removeAutostart } from '../relay/setup.js';
|
|
18
19
|
import { registerInstallCommands } from './relay-install.js';
|
|
@@ -268,6 +269,12 @@ relayCommand
|
|
|
268
269
|
console.error(clrError(`Server revoke failed: ${err?.message || err}`));
|
|
269
270
|
console.error(muted('Local token cleared anyway. Visit the web CLI to confirm the server-side revoke.'));
|
|
270
271
|
}
|
|
272
|
+
// Also revoke the relay's agent token (minted for spawned children).
|
|
273
|
+
// Best-effort: on failure it stays visible in `gipity token list`.
|
|
274
|
+
const agentTokenRevoked = await revokeRelayAgentToken();
|
|
275
|
+
if (!agentTokenRevoked && state.getAgentToken()) {
|
|
276
|
+
console.error(muted('Could not revoke the relay agent token - check `gipity token list`.'));
|
|
277
|
+
}
|
|
271
278
|
state.clearDevice();
|
|
272
279
|
// Remove the OS autostart unit too. Otherwise the login service relaunches
|
|
273
280
|
// `relay run`, which - finding no device but a valid login - silently
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Long-lived agent API token for relay-spawned children.
|
|
3
|
+
*
|
|
4
|
+
* The daemon itself authenticates with its device token, but the children it
|
|
5
|
+
* spawns (`gipity sync`, `gipity claude -p`) used to fall back to the shared
|
|
6
|
+
* session in ~/.gipity/auth.json - and with up to 6 concurrent dispatches,
|
|
7
|
+
* sibling processes raced each other on the session's SINGLE-USE refresh
|
|
8
|
+
* token, intermittently failing with "Session expired" mid-dispatch.
|
|
9
|
+
*
|
|
10
|
+
* Instead, the daemon holds a long-lived gip_at_* agent API token (minted
|
|
11
|
+
* once, stored in relay.json 0600 next to the device token) and exports it
|
|
12
|
+
* to every child as GIPITY_TOKEN. The CLI's auth layer prefers an env token
|
|
13
|
+
* over the session, so children authenticate statelessly - no refresh, no
|
|
14
|
+
* lock, no race. If minting fails (e.g. daemon started at boot with an
|
|
15
|
+
* expired session), we return null and children fall back to the session
|
|
16
|
+
* path, which is exactly the old behavior.
|
|
17
|
+
*/
|
|
18
|
+
import { hostname } from 'os';
|
|
19
|
+
import { post, del } from '../api.js';
|
|
20
|
+
import { resolveApiBase } from '../config.js';
|
|
21
|
+
import * as state from './state.js';
|
|
22
|
+
/** Validate a stored token against the API. Only a definitive 401 counts as
|
|
23
|
+
* invalid - transient failures (network, 5xx) must not discard a good token
|
|
24
|
+
* and trigger a re-mint storm. */
|
|
25
|
+
async function tokenValid(token) {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${resolveApiBase()}/users/me`, {
|
|
28
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
29
|
+
});
|
|
30
|
+
return res.status !== 401;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return true; // network blip - assume still valid
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Return the relay's agent token, minting one if absent or revoked. Minting
|
|
38
|
+
* uses the caller's normal session auth (the daemon process is logged in),
|
|
39
|
+
* so it can fail when the session is dead - callers treat null as "children
|
|
40
|
+
* use session auth" (graceful degradation to the old behavior).
|
|
41
|
+
*/
|
|
42
|
+
export async function ensureRelayAgentToken() {
|
|
43
|
+
const existing = state.getAgentToken();
|
|
44
|
+
if (existing) {
|
|
45
|
+
if (await tokenValid(existing.token))
|
|
46
|
+
return existing.token;
|
|
47
|
+
state.setAgentToken(null, null); // revoked/expired - drop and re-mint
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const name = `Relay on ${state.getDevice()?.name ?? hostname()}`;
|
|
51
|
+
const res = await post('/auth/agent-tokens', { name });
|
|
52
|
+
state.setAgentToken(res.data.token, res.data.shortGuid);
|
|
53
|
+
return res.data.token;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Best-effort server-side revocation of the relay's agent token (used when
|
|
60
|
+
* the device is unpaired). Local state is cleared by clearDevice() either
|
|
61
|
+
* way; a failed server call just leaves a dead-weight token the user can
|
|
62
|
+
* still see and revoke with `gipity token list` / `revoke`. */
|
|
63
|
+
export async function revokeRelayAgentToken() {
|
|
64
|
+
const existing = state.getAgentToken();
|
|
65
|
+
if (!existing?.guid)
|
|
66
|
+
return false;
|
|
67
|
+
try {
|
|
68
|
+
await del(`/auth/agent-tokens/${encodeURIComponent(existing.guid)}`);
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=agent-token.js.map
|
package/dist/relay/daemon.js
CHANGED
|
@@ -15,6 +15,7 @@ import { IngestQueue } from './ingest-queue.js';
|
|
|
15
15
|
import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
17
|
import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
|
|
18
|
+
import { ensureRelayAgentToken } from './agent-token.js';
|
|
18
19
|
import { redactEntries, redactString, normalizeSecrets } from './redact.js';
|
|
19
20
|
import { getMachineId } from './machine-id.js';
|
|
20
21
|
// Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
|
|
@@ -84,6 +85,19 @@ async function registerDevice() {
|
|
|
84
85
|
// so routine runs don't spam the log, but `gipity relay run --verbose`
|
|
85
86
|
// surfaces every dispatch decision for live troubleshooting.
|
|
86
87
|
let verboseMode = process.env.GIPITY_RELAY_VERBOSE === '1';
|
|
88
|
+
// Agent token exported to spawned children as GIPITY_TOKEN (see agent-token.ts).
|
|
89
|
+
// Resolved once at daemon startup; null = fall back to shared session auth.
|
|
90
|
+
let relayAgentToken = null;
|
|
91
|
+
/** Environment for relay-spawned `gipity` children. Adds GIPITY_TOKEN when the
|
|
92
|
+
* daemon holds an agent token so children authenticate statelessly instead of
|
|
93
|
+
* racing siblings on the session's single-use refresh token. */
|
|
94
|
+
function childEnv(extra = {}) {
|
|
95
|
+
return {
|
|
96
|
+
...process.env,
|
|
97
|
+
...(relayAgentToken ? { GIPITY_TOKEN: relayAgentToken } : {}),
|
|
98
|
+
...extra,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
87
101
|
function setVerbose(on) { verboseMode = verboseMode || on; }
|
|
88
102
|
// ANSI helpers - only colorize when stderr is a TTY.
|
|
89
103
|
const TTY = !!process.stderr.isTTY;
|
|
@@ -239,6 +253,18 @@ export async function run(opts = {}) {
|
|
|
239
253
|
process.on('exit', releasePid);
|
|
240
254
|
// Also release on our shutdown signals (exit handler sometimes doesn't fire).
|
|
241
255
|
ctx.abort.signal.addEventListener('abort', releasePid, { once: true });
|
|
256
|
+
// Long-lived agent token for spawned children (gipity sync / gipity claude).
|
|
257
|
+
// With it, children authenticate via GIPITY_TOKEN - stateless, no refresh -
|
|
258
|
+
// instead of racing sibling processes on the session's single-use refresh
|
|
259
|
+
// token. Minting needs a live session; on failure children fall back to
|
|
260
|
+
// session auth (the old behavior), so the daemon still runs.
|
|
261
|
+
relayAgentToken = await ensureRelayAgentToken();
|
|
262
|
+
if (relayAgentToken) {
|
|
263
|
+
log('info', 'agent token ready - spawned children use GIPITY_TOKEN');
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
log('warn', 'no agent token (mint failed or session dead) - children fall back to session auth');
|
|
267
|
+
}
|
|
242
268
|
log('info', 'relay started', { device: device.guid, name: device.name, pid: process.pid });
|
|
243
269
|
// Run all loops concurrently; exit when any returns (or abort fires).
|
|
244
270
|
// Cancellation poller runs alongside the dispatch loop so user-initiated
|
|
@@ -1131,7 +1157,7 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
|
|
|
1131
1157
|
return new Promise((resolve, reject) => {
|
|
1132
1158
|
const child = spawnCommand(cmd, ['sync', '--json'], {
|
|
1133
1159
|
cwd,
|
|
1134
|
-
env:
|
|
1160
|
+
env: childEnv(),
|
|
1135
1161
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1136
1162
|
});
|
|
1137
1163
|
// Hand the child to the caller so it can register the sync in `running`,
|
|
@@ -1187,7 +1213,7 @@ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
|
1187
1213
|
// feed the ephemeral live-typing channel (see stream-delta.ts); whole
|
|
1188
1214
|
// assistant/tool events still arrive unchanged for the persistent path.
|
|
1189
1215
|
const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
1190
|
-
const env = {
|
|
1216
|
+
const env = childEnv({ GIPITY_CONVERSATION_GUID: d.conversation_guid });
|
|
1191
1217
|
return new Promise((resolve, reject) => {
|
|
1192
1218
|
const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1193
1219
|
// `exited` fires when the child fully unwinds (exit event). Callers
|
package/dist/relay/state.js
CHANGED
|
@@ -38,6 +38,8 @@ export function loadState() {
|
|
|
38
38
|
paused: Boolean(raw.paused),
|
|
39
39
|
relay_enabled: typeof raw.relay_enabled === 'boolean' ? raw.relay_enabled : undefined,
|
|
40
40
|
onboard_shown: Boolean(raw.onboard_shown),
|
|
41
|
+
agent_token: typeof raw.agent_token === 'string' ? raw.agent_token : null,
|
|
42
|
+
agent_token_guid: typeof raw.agent_token_guid === 'string' ? raw.agent_token_guid : null,
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
45
|
catch {
|
|
@@ -73,8 +75,18 @@ export function setDevice(device) {
|
|
|
73
75
|
mutate(s => { s.device = device; });
|
|
74
76
|
}
|
|
75
77
|
export function clearDevice() {
|
|
76
|
-
// Forget the device → also clear pause flag
|
|
77
|
-
|
|
78
|
+
// Forget the device → also clear the pause flag and the agent token
|
|
79
|
+
// (both scoped to the device). Server-side token revocation is the
|
|
80
|
+
// caller's job (best-effort, see revokeRelayAgentToken).
|
|
81
|
+
mutate(s => { s.device = null; s.paused = false; s.agent_token = null; s.agent_token_guid = null; });
|
|
82
|
+
}
|
|
83
|
+
// ─── Agent token (exported to spawned children as GIPITY_TOKEN) ────────
|
|
84
|
+
export function getAgentToken() {
|
|
85
|
+
const s = loadState();
|
|
86
|
+
return s.agent_token ? { token: s.agent_token, guid: s.agent_token_guid ?? null } : null;
|
|
87
|
+
}
|
|
88
|
+
export function setAgentToken(token, guid) {
|
|
89
|
+
mutate(s => { s.agent_token = token; s.agent_token_guid = guid; });
|
|
78
90
|
}
|
|
79
91
|
// ─── Pause ─────────────────────────────────────────────────────────────
|
|
80
92
|
export function isPaused() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gipity",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.413",
|
|
4
4
|
"description": "The full-stack platform tuned for AI agents. Database, storage, auth, functions, deploy, and drop-in kits - all agent-tuned. Pair with Claude Code or use standalone.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"gipity": "dist/updater/shim.js",
|